Compare commits

...

9 Commits

Author SHA1 Message Date
Soulter
5360c3b106 feat: add short description support for plugin
short description will be displayed in the plugin card
2026-05-01 13:48:38 +08:00
Weilong Liao
ac5cb9b529 feat: supports to download plugins via astrbot official plugin storage (#7930)
* feat: supports to download plugins via astrbot official plugin storage

* fix: improve exception message for missing root directory name in PluginUpdator
2026-05-01 13:42:40 +08:00
Soulter
1aacb46289 fix: improve error message for invalid session format in SendMessageToUserTool
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 13:09:27 +08:00
Soulter
a23350109c perf: metrics 2026-05-01 01:47:33 +08:00
NayukiMeko
ffc31b305c fix(#7904): QQ官方私聊主动推送不再因缺少缓存 msg_id 而跳过发送 (#7914)
* fix: QQ官方私聊主动推送不再因缺少缓存 msg_id 而跳过发送 (#7904)

- 私聊场景下 _send_by_session_common 在无缓存 msg_id 时提前 return,
导致重启后 cron 等主动推送的消息无法发送。
- 私聊主动推送不需要 msg_id,跳过此检查。

* test: 补充 QQ 官方群聊有缓存 msg_id 时正常发送的测试 (#7904)

* Delete tests/unit/test_qqofficial_adapter.py

---------

Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
2026-05-01 00:41:40 +08:00
Soulter
6f83917336 feat: Enhance plugin detail and installation experience with new UI elements and internationalization support 2026-05-01 00:16:42 +08:00
Weilong Liao
2e49eb8455 feat: Implement plugin internationalization support (#7919)
* feat: Implement plugin internationalization support

- Added support for plugins to provide localized names, descriptions, and configuration texts through JSON files in the `.astrbot-plugin/i18n` directory.
- Updated various components to utilize the new internationalization functions, including `ConfigItemRenderer`, `ExtensionCard`, `ItemCard`, `ObjectEditor`, `PluginSetSelector`, and `TemplateListEditor`.
- Enhanced the `usePluginI18n` utility to resolve plugin-specific translations based on the current locale.
- Modified the `common` store to include an `i18n` field for plugin metadata.
- Updated documentation to include guidelines for plugin internationalization.
- Added tests to ensure proper loading of localization files and integration with plugin metadata.

* perf: code quality

* feat: update config path handling for internationalization support
2026-04-30 23:40:25 +08:00
LIghtJUNction
433836d972 fix: guard against None system_prompt in _ensure_persona_and_skills (#7880)
* fix: guard against None system_prompt in _ensure_persona_and_skills

ProviderRequest.system_prompt defaults to None. When a persona with a
prompt is configured, _ensure_persona_and_skills calls
``req.system_prompt += ...`` which crashes with ``TypeError`` when
system_prompt is None.

Added a None guard before the persona prompt injection and skills prompt
appending sections so they always operate on a string.

* chore: delete tests/unit/test_system_prompt_none_bug.py

---------

Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
2026-04-30 23:12:31 +08:00
Weilong Liao
d72cb78f37 feat: re-implement plugin pinning functionality for extensions (#7918)
* feat: re-implement plugin pinning functionality for extensions

Co-authored-by: Copilot <copilot@github.com>

* chore: update subset

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:17:52 +08:00
58 changed files with 2003 additions and 455 deletions

View File

@@ -0,0 +1,6 @@
{
"metadata": {
"display_name": "AstrBot",
"desc": "AstrBot's internal plugin, providing some basic capabilities."
}
}

View File

@@ -0,0 +1,6 @@
{
"metadata": {
"display_name": "AstrBot",
"desc": "AstrBot 的内部插件,提供一些基础能力。"
}
}

View File

@@ -1,10 +1,20 @@
import copy
import traceback
from sys import maxsize
import astrbot.api.message_components as Comp
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import Image, Plain
from astrbot.api.provider import LLMResponse, ProviderRequest
from astrbot.core import logger
from astrbot.core.utils.session_waiter import (
FILTERS,
USER_SESSIONS,
SessionController,
SessionWaiter,
session_waiter,
)
from .long_term_memory import LongTermMemory
@@ -18,6 +28,103 @@ class Main(star.Star):
except BaseException as e:
logger.error(f"聊天增强 err: {e}")
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) != 1:
return
is_empty_mention = (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
)
is_wake_prefix_only = (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in wake_prefix
)
if not (is_empty_mention or is_wake_prefix_only):
return
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
conversation = None
if curr_cid:
conversation = (
await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
curr_cid,
)
)
else:
curr_cid = (
await self.context.conversation_manager.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
)
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))
def ltm_enabled(self, event: AstrMessageEvent):
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"

View File

@@ -1,4 +1,4 @@
name: astrbot
desc: AstrBot 自带插件,包含人格注入、思考内容注入、群聊上下文感知等功能的实现,禁用后将无法使用这些功能。
author: Soulter
version: 4.1.0
desc: AstrBot's internal plugin, providing some basic capabilities.
author: AstrBot Team
version: 4.1.0

View File

@@ -0,0 +1,6 @@
{
"metadata": {
"display_name": "Built-in Commands",
"desc": "AstrBot's internal plugin, providing built-in commands such as /reset, /help, and /sid."
}
}

View File

@@ -0,0 +1,6 @@
{
"metadata": {
"display_name": "内置指令",
"desc": "AstrBot 自带插件,提供 /reset、/help、/sid 等内置指令。"
}
}

View File

@@ -1,4 +1,4 @@
name: builtin_commands
desc: AstrBot 自带指令,提供常用的对话管理、工具使用、插件管理等功能。
desc: AstrBot's internal plugin, providing all built-in commands such as /reset.
author: Soulter
version: 0.0.1

View File

@@ -1,115 +0,0 @@
import copy
from sys import maxsize
import astrbot.api.message_components as Comp
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.star import Context, Star
from astrbot.core.utils.session_waiter import (
FILTERS,
USER_SESSIONS,
SessionController,
SessionWaiter,
session_waiter,
)
class Main(Star):
"""会话控制"""
def __init__(self, context: Context) -> None:
super().__init__(context)
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""实现了对只有一个 @ 的消息内容的处理"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) == 1:
if (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
) or (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in wake_prefix
):
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
# 尝试使用 LLM 生成更生动的回复
# func_tools_mgr = self.context.get_llm_tool_manager()
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
conversation = None
if curr_cid:
conversation = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
curr_cid,
)
else:
# 创建新对话
curr_cid = await self.context.conversation_manager.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
# 使用 LLM 生成回复
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
# LLM 回复失败,使用原始预设回复
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
# 重新推入事件队列
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError as _:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))

View File

@@ -1,5 +0,0 @@
name: session_controller
desc: 为插件支持会话控制
author: Cvandia & Soulter
version: v1.0.1
repo: https://astrbot.app

View File

@@ -399,6 +399,9 @@ async def _ensure_persona_and_skills(
event, extract_persona_custom_error_message_from_persona(persona)
)
if req.system_prompt is None:
req.system_prompt = ""
if persona:
# Inject persona system prompt
if prompt := persona["prompt"]:

View File

@@ -1,4 +1,5 @@
import abc
import asyncio
import uuid
from asyncio import Queue
from collections.abc import Coroutine
@@ -138,7 +139,9 @@ class Platform(abc.ABC):
异步方法。
"""
await Metric.upload(msg_event_tick=1, adapter_name=self.meta().name)
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.meta().name)
)
def commit_event(self, event: AstrMessageEvent) -> None:
"""提交一个事件到事件队列。"""

View File

@@ -949,7 +949,9 @@ class LarkMessageEvent(AstrMessageEvent):
buffer.squash_plain()
await self.send(buffer)
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
)
self._has_send_oper = True
async def send_streaming(self, generator, use_fallback: bool = False):
@@ -1000,7 +1002,9 @@ class LarkMessageEvent(AstrMessageEvent):
if buffer:
buffer.squash_plain()
await self.send(buffer)
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
)
self._has_send_oper = True
async def _flush_and_close_card() -> None:
@@ -1075,8 +1079,10 @@ class LarkMessageEvent(AstrMessageEvent):
# If no text was produced at all, no card was created
if card_id is None:
if not fallback_used:
await Metric.upload(
msg_event_tick=1, adapter_name=self.platform_meta.name
asyncio.create_task(
Metric.upload(
msg_event_tick=1, adapter_name=self.platform_meta.name
)
)
self._has_send_oper = True
return
@@ -1084,5 +1090,7 @@ class LarkMessageEvent(AstrMessageEvent):
await _flush_and_close_card()
# 内联父类 send_streaming 的副作用
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
)
self._has_send_oper = True

View File

@@ -222,8 +222,9 @@ class QQOfficialPlatformAdapter(Platform):
):
return
# 私聊主动推送不需要 msg_id见 https://github.com/AstrBotDevs/AstrBot/issues/7904
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
if not msg_id and session.message_type != MessageType.FRIEND_MESSAGE:
logger.warning(
"[QQOfficial] No cached msg_id for session: %s, skip send_by_session",
session.session_id,

View File

@@ -27,6 +27,8 @@ class StarMetadata:
"""插件作者"""
desc: str | None = None
"""插件简介"""
short_desc: str | None = None
"""插件短简介"""
version: str | None = None
"""插件版本"""
repo: str | None = None
@@ -67,6 +69,9 @@ class StarMetadata:
astrbot_version: str | None = None
"""插件要求的 AstrBot 版本范围PEP 440 specifier如 >=4.13.0,<4.17.0"""
i18n: dict[str, dict] = field(default_factory=dict)
"""插件自带的国际化文案,按 locale 分组。"""
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"

View File

@@ -13,6 +13,7 @@ import tempfile
import traceback
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from types import ModuleType
import yaml
@@ -496,6 +497,11 @@ class PluginManager:
name=metadata["name"],
author=metadata["author"],
desc=metadata["desc"],
short_desc=(
metadata["short_desc"]
if isinstance(metadata.get("short_desc"), str)
else None
),
version=metadata["version"],
repo=metadata["repo"] if "repo" in metadata else None,
display_name=metadata.get("display_name", None),
@@ -513,10 +519,49 @@ class PluginManager:
if isinstance(metadata.get("astrbot_version"), str)
else None
),
i18n=PluginManager._load_plugin_i18n(plugin_path),
)
return metadata
@staticmethod
def _load_plugin_i18n(plugin_path: str) -> dict[str, dict]:
plugin_root = Path(plugin_path)
i18n_dir = plugin_root / ".astrbot-plugin" / "i18n"
if not i18n_dir.is_dir():
return {}
translations: dict[str, dict] = {}
try:
for file_path in i18n_dir.iterdir():
if file_path.suffix.lower() != ".json":
continue
locale = file_path.stem
if not locale or len(locale) > 32:
continue
if not file_path.is_file():
continue
if file_path.stat().st_size > 1024 * 1024:
logger.warning("插件 i18n 文件超过 1MB已跳过: %s", file_path)
continue
try:
with file_path.open(encoding="utf-8") as f:
locale_data = json.load(f)
if isinstance(locale_data, dict):
translations[locale] = locale_data
else:
logger.warning(
"插件 i18n 文件内容不是 JSON object已跳过: %s",
file_path,
)
except Exception as exc:
logger.warning("加载插件 i18n 文件失败 %s: %s", file_path, exc)
except OSError as exc:
logger.warning("读取插件 i18n 目录失败 %s: %s", i18n_dir, exc)
return translations
@staticmethod
def _normalize_plugin_dir_name(plugin_name: str) -> str:
return plugin_name.strip()
@@ -696,6 +741,7 @@ class PluginManager:
"name": metadata.name,
"author": metadata.author,
"desc": metadata.desc,
"short_desc": metadata.short_desc,
"version": metadata.version,
"repo": metadata.repo,
"display_name": metadata.display_name,
@@ -937,11 +983,13 @@ class PluginManager:
metadata.name = metadata_yaml.name
metadata.author = metadata_yaml.author
metadata.desc = metadata_yaml.desc
metadata.short_desc = metadata_yaml.short_desc
metadata.version = metadata_yaml.version
metadata.repo = metadata_yaml.repo
metadata.display_name = metadata_yaml.display_name
metadata.support_platforms = metadata_yaml.support_platforms
metadata.astrbot_version = metadata_yaml.astrbot_version
metadata.i18n = metadata_yaml.i18n
except Exception as e:
logger.warning(
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
@@ -1306,7 +1354,11 @@ class PluginManager:
self._rebuild_failed_plugin_info()
async def install_plugin(
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
self,
repo_url: str,
proxy: str = "",
ignore_version_check: bool = False,
download_url: str = "",
):
"""从仓库 URL 安装插件
@@ -1315,6 +1367,7 @@ class PluginManager:
Args:
repo_url (str): 要安装的插件仓库 URL
proxy (str, optional): 用于下载的代理服务器。默认为空字符串。
download_url (str, optional): 插件压缩包下载地址。提供时优先从此地址下载安装包。
Returns:
dict | None: 安装成功时返回包含插件信息的字典:
@@ -1342,7 +1395,14 @@ class PluginManager:
raise Exception(
f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。"
)
plugin_path = await self.updator.install(repo_url, proxy)
if download_url:
plugin_path = await self.updator.install(
repo_url,
proxy,
download_url=download_url,
)
else:
plugin_path = await self.updator.install(repo_url, proxy)
# reload the plugin
dir_name = os.path.basename(plugin_path)

View File

@@ -18,11 +18,15 @@ class PluginUpdator(RepoZipUpdator):
def get_plugin_store_path(self) -> str:
return self.plugin_store_path
async def install(self, repo_url: str, proxy="") -> str:
async def install(self, repo_url: str, proxy="", download_url: str = "") -> str:
_, repo_name, _ = self.parse_github_url(repo_url)
repo_name = self.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
await self.download_from_repo_url(plugin_path, repo_url, proxy)
if download_url:
logger.info(f"Downloading plugin archive for {repo_name}: {download_url}")
await self._download_file(download_url, plugin_path + ".zip")
else:
await self.download_from_repo_url(plugin_path, repo_url, proxy)
self.unzip_file(plugin_path + ".zip", plugin_path)
return plugin_path
@@ -31,21 +35,25 @@ class PluginUpdator(RepoZipUpdator):
repo_url = plugin.repo
if not repo_url:
raise Exception(f"插件 {plugin.name} 没有指定仓库地址。")
raise Exception(f"Plugin {plugin.name} does not specify a repository URL.")
if not plugin.root_dir_name:
raise Exception(f"插件 {plugin.name} 的根目录名未指定。")
raise Exception(
f"Plugin {plugin.name} does not specify a root directory name."
)
plugin_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
logger.info(f"正在更新插件,路径: {plugin_path},仓库地址: {repo_url}")
logger.info(
f"Updating plugin at path: {plugin_path}, repository URL: {repo_url}",
)
await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy)
try:
remove_dir(plugin_path)
except BaseException as e:
logger.error(
f"删除旧版本插件 {plugin_path} 文件夹失败: {e!s},使用覆盖安装。",
f"Failed to remove old plugin directory {plugin_path}: {e!s}; using overwrite installation.",
)
self.unzip_file(plugin_path + ".zip", plugin_path)
@@ -55,7 +63,7 @@ class PluginUpdator(RepoZipUpdator):
def unzip_file(self, zip_path: str, target_dir: str) -> None:
os.makedirs(target_dir, exist_ok=True)
update_dir = ""
logger.info(f"正在解压压缩包: {zip_path}")
logger.info(f"Extracting archive: {zip_path}")
with zipfile.ZipFile(zip_path, "r") as z:
update_dir = z.namelist()[0]
z.extractall(target_dir)
@@ -71,11 +79,11 @@ class PluginUpdator(RepoZipUpdator):
try:
logger.info(
f"删除临时文件: {zip_path} {os.path.join(target_dir, update_dir)}",
f"Removing temporary files: {zip_path} and {os.path.join(target_dir, update_dir)}",
)
shutil.rmtree(os.path.join(target_dir, update_dir), onerror=on_error)
os.remove(zip_path)
except BaseException:
logger.warning(
f"删除更新文件失败,可以手动删除 {zip_path} {os.path.join(target_dir, update_dir)}",
f"Failed to remove update files; you can manually delete {zip_path} and {os.path.join(target_dir, update_dir)}",
)

View File

@@ -220,7 +220,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
else session
)
except Exception as exc:
return f"error: invalid session: {exc}"
return f"error: invalid session: {exc} - session should be a string in the format of 'platform_id:platform_type:session_id'."
await context.context.context.send_message(
target_session,

View File

@@ -1,7 +1,10 @@
import asyncio
import os
import socket
import sys
import uuid
from contextlib import suppress
from typing import Any
import aiohttp
@@ -11,6 +14,14 @@ from astrbot.core.config import VERSION
class Metric:
_iid_cache = None
_has_uploaded_once = False
_upload_interval_seconds = 10 * 60
_max_pending_metric_groups = 64
_counter_fields = {"llm_tick", "msg_event_tick"}
_pending_metrics: dict[tuple[tuple[str, str], ...], dict[str, Any]] = {}
_flush_task: asyncio.Task | None = None
_lock: asyncio.Lock | None = None
_lock_loop: asyncio.AbstractEventLoop | None = None
@staticmethod
def get_installation_id():
@@ -40,25 +51,49 @@ class Metric:
return "null"
@staticmethod
async def upload(**kwargs) -> None:
"""上传相关非敏感的指标以更好地了解 AstrBot 的使用情况。上传的指标不会包含任何有关消息文本、用户信息等敏感信息。
def _get_lock() -> asyncio.Lock:
loop = asyncio.get_running_loop()
if Metric._lock is None or Metric._lock_loop is not loop:
Metric._lock = asyncio.Lock()
Metric._lock_loop = loop
return Metric._lock
Powered by TickStats.
"""
if os.environ.get("ASTRBOT_DISABLE_METRICS", "0") == "1":
return
base_url = "https://tickstats.soulter.top/api/metric/90a6c2a1"
kwargs["v"] = VERSION
kwargs["os"] = sys.platform
payload = {"metrics_data": kwargs}
@staticmethod
def _format_group_value(value: Any) -> str:
return repr(value)
@staticmethod
def _get_metric_group_key(kwargs: dict[str, Any]) -> tuple[tuple[str, str], ...]:
return tuple(
sorted(
(key, Metric._format_group_value(value))
for key, value in kwargs.items()
if key not in Metric._counter_fields
)
)
@staticmethod
def _get_metric_group_fields(kwargs: dict[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in kwargs.items()
if key not in Metric._counter_fields
}
@staticmethod
def _coerce_counter(value: Any) -> int:
try:
kwargs["hn"] = socket.gethostname()
except Exception:
pass
try:
kwargs["iid"] = Metric.get_installation_id()
except Exception:
pass
return int(value)
except (TypeError, ValueError):
return 0
@staticmethod
def _ensure_flush_task_locked() -> None:
if Metric._flush_task is None or Metric._flush_task.done():
Metric._flush_task = asyncio.create_task(Metric._flush_periodically())
@staticmethod
async def _save_platform_stats(kwargs: dict[str, Any]) -> None:
try:
if "adapter_name" in kwargs:
await db_helper.insert_platform_stats(
@@ -68,6 +103,93 @@ class Metric:
except Exception as e:
logger.error(f"保存指标到数据库失败: {e}")
@staticmethod
async def _add_pending_metrics(kwargs: dict[str, Any]) -> None:
key = Metric._get_metric_group_key(kwargs)
immediate_metrics = None
should_flush = False
lock = Metric._get_lock()
async with lock:
if not Metric._has_uploaded_once:
Metric._has_uploaded_once = True
immediate_metrics = dict(kwargs)
else:
pending = Metric._pending_metrics.setdefault(
key,
Metric._get_metric_group_fields(kwargs),
)
for counter_field in Metric._counter_fields:
if counter_field in kwargs:
pending[counter_field] = pending.get(
counter_field,
0,
) + Metric._coerce_counter(kwargs[counter_field])
Metric._ensure_flush_task_locked()
should_flush = (
len(Metric._pending_metrics) > Metric._max_pending_metric_groups
)
if immediate_metrics is not None:
await Metric._post_metrics(immediate_metrics)
return
if should_flush:
await Metric.flush()
@staticmethod
async def _flush_periodically() -> None:
try:
while True:
await asyncio.sleep(Metric._upload_interval_seconds)
await Metric.flush()
lock = Metric._get_lock()
async with lock:
if not Metric._pending_metrics:
Metric._flush_task = None
return
except asyncio.CancelledError:
raise
except Exception:
pass
finally:
current_task = asyncio.current_task()
with suppress(RuntimeError):
lock = Metric._get_lock()
async with lock:
if Metric._flush_task is current_task:
Metric._flush_task = None
@staticmethod
async def flush() -> None:
"""Flush pending metrics immediately."""
lock = Metric._get_lock()
async with lock:
pending_metrics = list(Metric._pending_metrics.values())
Metric._pending_metrics = {}
for metrics_data in pending_metrics:
await Metric._post_metrics(metrics_data)
await asyncio.sleep(0.25)
@staticmethod
async def _post_metrics(metrics_data: dict[str, Any]) -> None:
if os.environ.get("ASTRBOT_DISABLE_METRICS", "0") == "1":
return
base_url = "https://tickstats.soulter.top/api/metric/90a6c2a1"
payload_metrics = dict(metrics_data)
payload_metrics["v"] = VERSION
payload_metrics["os"] = sys.platform
try:
payload_metrics["hn"] = socket.gethostname()
except Exception:
pass
try:
payload_metrics["iid"] = Metric.get_installation_id()
except Exception:
pass
payload = {"metrics_data": payload_metrics}
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(base_url, json=payload, timeout=3) as response:
@@ -75,3 +197,15 @@ class Metric:
pass
except Exception:
pass
@staticmethod
async def upload(**kwargs) -> None:
"""上传相关非敏感的指标以更好地了解 AstrBot 的使用情况。上传的指标不会包含任何有关消息文本、用户信息等敏感信息。
Powered by TickStats.
"""
if os.environ.get("ASTRBOT_DISABLE_METRICS", "0") == "1":
return
await Metric._save_platform_stats(kwargs)
await Metric._add_pending_metrics(dict(kwargs))

View File

@@ -1498,7 +1498,7 @@ class ConfigRoute(Route):
}
async def _get_plugin_config(self, plugin_name: str):
ret: dict = {"metadata": None, "config": None}
ret: dict = {"metadata": None, "config": None, "i18n": {}}
for plugin_md in star_registry:
if plugin_md.name == plugin_name:
@@ -1514,6 +1514,7 @@ class ConfigRoute(Route):
"items": plugin_md.config.schema, # 初始化时通过 __setattr__ 存入了 schema
},
}
ret["i18n"] = plugin_md.i18n
break
return ret

View File

@@ -409,6 +409,7 @@ class PluginRoute(Route):
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
"installed_at": self._get_plugin_installed_at(plugin),
"i18n": plugin.i18n,
}
# 检查是否为全空的幽灵插件
if not any(
@@ -503,6 +504,7 @@ class PluginRoute(Route):
post_data = await request.get_json()
repo_url = post_data["url"]
download_url = str(post_data.get("download_url") or "").strip()
ignore_version_check = bool(post_data.get("ignore_version_check", False))
proxy: str = post_data.get("proxy", None)
@@ -515,6 +517,7 @@ class PluginRoute(Route):
repo_url,
proxy,
ignore_version_check=ignore_version_check,
download_url=download_url,
)
# self.core_lifecycle.restart()
logger.info(f"安装插件 {repo_url} 成功。")

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 257 icons */
/* Auto-generated MDI subset 259 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -784,6 +784,14 @@
content: "\F03F6";
}
.mdi-pin::before {
content: "\F0403";
}
.mdi-pin-outline::before {
content: "\F0931";
}
.mdi-play::before {
content: "\F040A";
}

View File

@@ -1,9 +1,11 @@
<script setup>
import { ref, computed } from "vue";
import { computed } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import PluginPlatformChip from "@/components/shared/PluginPlatformChip.vue";
import { usePluginI18n } from "@/utils/pluginI18n";
const { tm } = useModuleI18n("features/extension");
const { pluginShortDesc } = usePluginI18n();
const props = defineProps({
plugin: {
@@ -20,7 +22,7 @@ const props = defineProps({
},
});
const emit = defineEmits(["install"]);
const emit = defineEmits(["install", "open"]);
const normalizePlatformList = (platforms) => {
if (!Array.isArray(platforms)) return [];
@@ -31,16 +33,27 @@ const platformDisplayList = computed(() =>
normalizePlatformList(props.plugin?.support_platforms),
);
const cardDescription = computed(() =>
pluginShortDesc(props.plugin, props.plugin?.short_desc || props.plugin?.desc || ""),
);
const handleInstall = (plugin) => {
emit("install", plugin);
};
const handleOpen = () => {
emit("open", props.plugin);
};
</script>
<template>
<v-card
class="rounded-lg d-flex flex-column plugin-card"
variant="outlined"
elevation="0"
:ripple="false"
@click="handleOpen"
>
<v-card-text
@@ -74,6 +87,15 @@ const handleInstall = (plugin) => {
>
{{ tm("market.recommended") }}
</v-chip>
<v-chip
v-if="plugin?.astrbot_compatible === false"
color="error"
size="x-small"
label
class="market-incompatible-chip"
>
{{ tm("status.incompatible") }}
</v-chip>
</div>
<div class="d-flex align-center plugin-meta">
@@ -110,17 +132,6 @@ const handleInstall = (plugin) => {
>
{{ plugin.author }}
</span>
<div
class="d-flex align-center text-subtitle-2 ml-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-source-branch"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.version }}</span>
</div>
<div
v-if="plugin.stars !== undefined"
class="d-flex align-center text-subtitle-2 ml-2"
@@ -136,27 +147,7 @@ const handleInstall = (plugin) => {
</div>
<div class="text-caption plugin-description">
{{ plugin.desc }}
</div>
<div
v-if="plugin.astrbot_version || platformDisplayList.length"
class="plugin-badges"
>
<v-chip
v-if="plugin.astrbot_version"
size="x-small"
color="secondary"
variant="outlined"
style="height: 20px"
>
AstrBot: {{ plugin.astrbot_version }}
</v-chip>
<PluginPlatformChip
:platforms="plugin.support_platforms"
size="x-small"
:chip-style="{ height: '20px' }"
/>
{{ cardDescription }}
</div>
<div class="plugin-stats"></div>
@@ -167,36 +158,16 @@ const handleInstall = (plugin) => {
style="gap: 6px; padding: 8px 12px; padding-top: 0"
@click.stop
>
<v-chip
v-for="tag in plugin.tags?.slice(0, 2)"
:key="tag"
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="x-small"
style="height: 20px"
<div
v-if="platformDisplayList.length"
class="plugin-badges"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<v-menu v-if="plugin.tags && plugin.tags.length > 2" open-on-hover offset-y>
<template v-slot:activator="{ props: menuProps }">
<v-chip
v-bind="menuProps"
color="grey"
label
size="x-small"
style="height: 20px; cursor: pointer"
>
+{{ plugin.tags.length - 2 }}
</v-chip>
</template>
<v-list density="compact">
<v-list-item v-for="tag in plugin.tags.slice(2)" :key="tag">
<v-chip :color="tag === 'danger' ? 'error' : 'primary'" label size="small">
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
</v-list-item>
</v-list>
</v-menu>
<PluginPlatformChip
:platforms="plugin.support_platforms"
size="x-small"
:chip-style="{ height: '20px' }"
/>
</div>
<v-spacer></v-spacer>
<v-btn
v-if="plugin?.repo"
@@ -238,6 +209,22 @@ const handleInstall = (plugin) => {
</template>
<style scoped>
.plugin-card {
background: rgb(var(--v-theme-surface));
cursor: pointer;
transition: background-color 0.16s ease;
}
.plugin-card:hover,
.plugin-card:focus-within {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.plugin-card :deep(.v-card__overlay),
.plugin-card :deep(.v-ripple__container) {
display: none;
}
.plugin-card-content {
padding: 12px;
padding-bottom: 8px;
@@ -286,6 +273,12 @@ const handleInstall = (plugin) => {
height: 20px;
}
.market-incompatible-chip {
flex-shrink: 0;
font-weight: 700;
height: 20px;
}
.plugin-title {
line-height: 1.3;
font-size: 1rem;
@@ -307,11 +300,11 @@ const handleInstall = (plugin) => {
flex: 1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
min-height: calc(1.3em * 3);
max-height: calc(1.3em * 3);
min-height: calc(1.3em * 2);
max-height: calc(1.3em * 2);
}
.plugin-badges {

View File

@@ -4,6 +4,7 @@ import { ref, computed } from 'vue'
import ConfigItemRenderer from './ConfigItemRenderer.vue'
import TemplateListEditor from './TemplateListEditor.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useConfigTextResolver } from '@/composables/useConfigTextResolver'
import axios from 'axios'
import { useToast } from '@/utils/toast'
@@ -24,6 +25,10 @@ const props = defineProps({
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
pathPrefix: {
type: String,
default: ''
@@ -35,12 +40,9 @@ const props = defineProps({
})
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
}
const { getRaw } = useModuleI18n('features/config-metadata')
const { translateIfKey, resolveConfigText } = useConfigTextResolver(props)
const currentConfigPath = computed(() => props.pathPrefix || props.metadataKey)
const filteredIterable = computed(() => {
if (!props.iterable) return {}
@@ -174,11 +176,11 @@ function hasVisibleItemsAfter(items, currentIndex) {
<template>
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
{{ translateIfKey(metadata[metadataKey]?.description) }} <span class="metadata-key">({{ metadataKey }})</span>
{{ resolveConfigText(currentConfigPath, 'description', metadata[metadataKey]?.description) }} <span class="metadata-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ translateIfKey(metadata[metadataKey]?.hint) }}
{{ resolveConfigText(currentConfigPath, 'hint', metadata[metadataKey]?.hint) }}
</v-list-item-subtitle>
</div>
@@ -207,6 +209,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
:iterable="iterable[key]"
:metadataKey="key"
:pluginName="pluginName"
:pluginI18n="pluginI18n"
:pathPrefix="getItemPath(key)"
>
</AstrBotConfig>
@@ -220,19 +223,22 @@ function hasVisibleItemsAfter(items, currentIndex) {
<div class="config-section mb-2">
<v-list-item-title class="config-title">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ translateIfKey(metadata[metadataKey].items[key]?.description) }}
{{ resolveConfigText(getItemPath(key), 'description', metadata[metadataKey].items[key]?.description) }}
<span class="property-key">({{ key }})</span>
</span>
<span v-else>{{ key }}</span>
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint" class="important-hint"></span>
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }}
{{ resolveConfigText(getItemPath(key), 'hint', metadata[metadataKey].items[key]?.hint) }}
</v-list-item-subtitle>
</div>
<TemplateListEditor
v-model="iterable[key]"
:templates="metadata[metadataKey].items[key]?.templates || {}"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-path="getItemPath(key)"
class="config-field"
/>
</div>
@@ -245,7 +251,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-list-item density="compact">
<v-list-item-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ translateIfKey(metadata[metadataKey].items[key]?.description) }}
{{ resolveConfigText(getItemPath(key), 'description', metadata[metadataKey].items[key]?.description) }}
<span class="property-key">({{ key }})</span>
</span>
<span v-else>{{ key }}</span>
@@ -254,7 +260,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
class="important-hint"></span>
{{ translateIfKey(getItemHint(key, metadata[metadataKey].items[key])) }}
{{ resolveConfigText(getItemPath(key), 'hint', getItemHint(key, metadata[metadataKey].items[key])) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -264,6 +270,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-model="iterable[key]"
:item-meta="metadata[metadataKey].items[key] || null"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="getItemPath(key)"
:loading="loadingEmbeddingDim"
:show-fullscreen-btn="!!metadata[metadataKey].items[key]?.editor_mode"
@@ -287,13 +294,13 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-col cols="12" sm="7" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ metadata[metadataKey]?.description }}
{{ resolveConfigText(getItemPath(metadataKey), 'description', metadata[metadataKey]?.description) }}
<span class="property-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
{{ resolveConfigText(getItemPath(metadataKey), 'hint', metadata[metadataKey]?.hint) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -303,6 +310,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-if="metadata[metadataKey]?.type === 'template_list' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:templates="metadata[metadataKey]?.templates || {}"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-path="getItemPath(metadataKey)"
class="config-field"
/>
<ConfigItemRenderer
@@ -310,6 +320,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-model="iterable[metadataKey]"
:item-meta="metadata[metadataKey]"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="getItemPath(metadataKey)"
/>
</v-col>

View File

@@ -6,6 +6,7 @@ import ConfigItemRenderer from './ConfigItemRenderer.vue'
import TemplateListEditor from './TemplateListEditor.vue'
import PersonaQuickPreview from './PersonaQuickPreview.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useConfigTextResolver } from '@/composables/useConfigTextResolver'
const props = defineProps({
@@ -28,20 +29,15 @@ const props = defineProps({
})
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { getRaw } = useModuleI18n('features/config-metadata')
const { tm: tmConfig } = useModuleI18n('features/config')
const { translateIfKey } = useConfigTextResolver()
const hintMarkdown = new MarkdownIt({
linkify: true,
breaks: true
})
// 翻译器函数 - 如果是国际化键则翻译,否则原样返回
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return tm(value)
}
const renderHint = (value) => {
const text = translateIfKey(value)
if (!text) return ''

View File

@@ -213,6 +213,9 @@
v-else-if="itemMeta?.type === 'dict'"
:model-value="modelValue"
:item-meta="itemMeta"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="configKey"
@update:model-value="emitUpdate"
class="config-field"
/>
@@ -241,6 +244,7 @@ import PluginSetSelector from './PluginSetSelector.vue'
import T2ITemplateEditor from './T2ITemplateEditor.vue'
import { computed, ref } from 'vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { usePluginI18n } from '@/utils/pluginI18n'
const numericTemp = ref(null)
const listSearchText = ref('')
@@ -258,6 +262,10 @@ const props = defineProps({
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
configKey: {
type: String,
default: ''
@@ -275,6 +283,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'get-embedding-dim', 'open-fullscreen'])
const { t } = useI18n()
const { getRaw } = useModuleI18n('features/config-metadata')
const { configText } = usePluginI18n()
function emitUpdate(val) {
emit('update:modelValue', val)
@@ -297,6 +306,17 @@ function getLabel(itemMeta, index, option) {
}
function getTranslatedLabels(itemMeta) {
if (
props.pluginName
&& props.configKey
&& props.pluginI18n
&& Object.keys(props.pluginI18n).length > 0
) {
const translatedLabels = configText(props.pluginI18n, props.configKey, 'labels', null)
if (Array.isArray(translatedLabels)) {
return translatedLabels
}
}
if (!itemMeta?.labels) return null
if (typeof itemMeta.labels === 'string') {
const translatedLabels = getRaw(itemMeta.labels)

View File

@@ -6,6 +6,7 @@ import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
import PluginPlatformChip from "./PluginPlatformChip.vue";
import StyledMenu from "./StyledMenu.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { usePluginI18n } from "@/utils/pluginI18n";
const props = defineProps({
extension: {
@@ -20,6 +21,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isPinned: {
type: Boolean,
default: false,
},
});
// 定义要发送到父组件的事件
@@ -32,6 +37,7 @@ const emit = defineEmits([
"view-handlers",
"view-readme",
"view-changelog",
"toggle-pin",
]);
const showUninstallDialog = ref(false);
@@ -40,6 +46,7 @@ const attrs = useAttrs();
// 国际化
const { tm } = useModuleI18n("features/extension");
const { pluginName, pluginDesc } = usePluginI18n();
const supportPlatforms = computed(() => {
const platforms = props.extension?.support_platforms;
@@ -68,6 +75,10 @@ const logoSrc = computed(() => {
: defaultPluginIcon;
});
const localizedName = computed(() => pluginName(props.extension));
const localizedDesc = computed(() => pluginDesc(props.extension));
watch(
() => props.extension?.logo,
() => {
@@ -115,6 +126,10 @@ const viewChangelog = () => {
emit("view-changelog", props.extension);
};
const togglePin = () => {
emit("toggle-pin", props.extension);
};
</script>
<template>
@@ -161,17 +176,15 @@ const viewChangelog = () => {
<v-tooltip
location="top"
:text="
extension.display_name?.length &&
extension.display_name !== extension.name
? `${extension.display_name} (${extension.name})`
localizedName?.length &&
localizedName !== extension.name
? `${localizedName} (${extension.name})`
: extension.name
"
>
<template v-slot:activator="{ props: titleTooltipProps }">
<span v-bind="titleTooltipProps" class="extension-title__text">{{
extension.display_name?.length
? extension.display_name
: extension.name
localizedName
}}</span>
</template>
</v-tooltip>
@@ -271,7 +284,7 @@ const viewChangelog = () => {
class="extension-desc"
:class="{ 'text-caption': $vuetify.display.xs }"
>
{{ extension.desc }}
{{ localizedDesc }}
</div>
</div>
</div>
@@ -280,6 +293,22 @@ const viewChangelog = () => {
<v-card-actions class="extension-actions" @click.stop>
<template v-if="!marketMode">
<v-spacer></v-spacer>
<v-tooltip location="top">
<template v-slot:activator="{ props: pinTooltipProps }">
<v-btn
v-bind="pinTooltipProps"
:aria-label="isPinned ? tm('buttons.unpin') : tm('buttons.pin')"
:color="isPinned ? 'primary' : 'secondary'"
:icon="isPinned ? 'mdi-pin' : 'mdi-pin-outline'"
size="small"
variant="tonal"
class="extension-pin-btn"
@click="togglePin"
></v-btn>
</template>
<span>{{ isPinned ? tm("buttons.unpin") : tm("buttons.pin") }}</span>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
@@ -306,20 +335,6 @@ const viewChangelog = () => {
</template>
</v-tooltip>
<v-tooltip v-if="extension?.repo" location="top" :text="tm('buttons.viewRepo')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-github"
size="small"
variant="tonal"
color="secondary"
:href="extension.repo"
target="_blank"
></v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
@@ -466,6 +481,10 @@ const viewChangelog = () => {
flex-shrink: 0;
}
.extension-pin-btn {
flex-shrink: 0;
}
.extension-switch-wrap :deep(.v-switch) {
margin: 0;
}

View File

@@ -1,5 +1,5 @@
<template>
<v-card class="item-card hover-elevation" style="padding: 4px;" elevation="0">
<v-card class="item-card hover-elevation" style="padding: 4px;" :variant="variant" elevation="0">
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h2 text-truncate" :title="getItemTitle()">{{ getItemTitle() }}</span>
<v-tooltip location="top">
@@ -116,6 +116,10 @@ export default {
disableDelete: {
type: Boolean,
default: false
},
variant: {
type: String,
default: undefined
}
},
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
@@ -135,9 +139,10 @@ export default {
<style scoped>
.item-card {
background: rgb(var(--v-theme-surface));
position: relative;
border-radius: 18px;
transition: all 0.3s ease;
transition: background-color 0.16s ease, transform 0.3s ease;
overflow: hidden;
min-height: 220px;
height: 100%;
@@ -147,6 +152,7 @@ export default {
}
.hover-elevation:hover {
background: rgba(var(--v-theme-on-surface), 0.04);
transform: translateY(-2px);
}

View File

@@ -115,7 +115,7 @@
<v-col cols="4">
<div class="d-flex flex-column">
<span class="text-caption font-weight-medium">{{ getTemplateTitle(template, templateKey) }}</span>
<span v-if="template.hint" class="text-caption text-grey" style="font-size: 0.7rem;">{{ translateIfKey(template.hint) }}</span>
<span v-if="template.hint" class="text-caption text-grey" style="font-size: 0.7rem;">{{ resolveTemplateText(templateKey, 'hint', template.hint) }}</span>
</div>
</v-col>
<v-col cols="7" class="pl-2 d-flex align-center justify-end">
@@ -221,11 +221,11 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useI18n } from '@/i18n/composables'
import { useToast } from '@/utils/toast'
import { useConfigTextResolver } from '@/composables/useConfigTextResolver'
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { warning: toastWarning } = useToast()
const props = defineProps({
@@ -237,6 +237,18 @@ const props = defineProps({
type: Object,
default: null
},
pluginName: {
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
configKey: {
type: String,
default: ''
},
buttonText: {
type: String,
default: ''
@@ -251,6 +263,8 @@ const props = defineProps({
}
})
const { translateIfKey, resolveConfigText } = useConfigTextResolver(props)
const emit = defineEmits(['update:modelValue'])
const resolveButtonText = computed(() => props.buttonText || t('core.common.list.modifyButton'))
@@ -515,13 +529,15 @@ function cancelDialog() {
dialog.value = false
}
function translateIfKey(value) {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
function getTemplateTitle(template, templateKey) {
return resolveTemplateText(templateKey, 'name', template?.name || template?.description || templateKey)
}
function getTemplateTitle(template, templateKey) {
return translateIfKey(template?.name || template?.description || templateKey)
function resolveTemplateText(templateKey, attr, fallback) {
if (!props.configKey) {
return translateIfKey(fallback) || ''
}
return resolveConfigText(`${props.configKey}.template_schema.${templateKey}`, attr, fallback)
}
</script>
@@ -538,4 +554,3 @@ function getTemplateTitle(template, templateKey) {
opacity: 0.8;
}
</style>

View File

@@ -66,9 +66,9 @@
></v-checkbox>
</template>
<v-list-item-title>{{ plugin.name }}</v-list-item-title>
<v-list-item-title>{{ pluginDisplayName(plugin) }}</v-list-item-title>
<v-list-item-subtitle>
{{ plugin.desc || tm('pluginSetSelector.noDescription') }}
{{ pluginDescription(plugin) || tm('pluginSetSelector.noDescription') }}
<v-chip v-if="!plugin.activated" size="x-small" color="grey" class="ml-1">
{{ tm('pluginSetSelector.notActivated') }}
</v-chip>
@@ -105,6 +105,7 @@
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import { usePluginI18n } from '@/utils/pluginI18n'
const props = defineProps({
modelValue: {
@@ -123,6 +124,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('core.shared')
const { pluginName, pluginDesc } = usePluginI18n()
const dialog = ref(false)
const pluginList = ref([])
@@ -130,6 +132,9 @@ const loading = ref(false)
const selectionMode = ref('custom') // 'all', 'none', 'custom'
const selectedPlugins = ref([])
const pluginDisplayName = (plugin) => pluginName(plugin) || plugin.name
const pluginDescription = (plugin) => pluginDesc(plugin)
// 判断是否为"所有插件"模式
const isAllPlugins = computed(() => {
return props.modelValue && props.modelValue.length === 1 && props.modelValue[0] === '*'

View File

@@ -19,8 +19,8 @@
:key="option.value"
@click="addEntry(option.value)"
>
<v-list-item-title>{{ translateIfKey(option.label) }}</v-list-item-title>
<v-list-item-subtitle v-if="option.hint">{{ translateIfKey(option.hint) }}</v-list-item-subtitle>
<v-list-item-title>{{ option.label }}</v-list-item-title>
<v-list-item-subtitle v-if="option.hint">{{ option.hint }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-menu>
@@ -58,7 +58,7 @@
<div class="d-flex flex-column">
<v-list-item-title class="property-name">{{ templateLabel(entry.__template_key) }}</v-list-item-title>
<v-list-item-subtitle class="property-hint" v-if="getTemplate(entry)?.hint || getTemplate(entry)?.description">
{{ translateIfKey(getTemplate(entry)?.hint || getTemplate(entry)?.description) }}
{{ templateText(entry.__template_key, 'hint', getTemplate(entry)?.hint || getTemplate(entry)?.description) }}
</v-list-item-subtitle>
</div>
</div>
@@ -82,10 +82,10 @@
>
<div class="config-section mb-2">
<v-list-item-title class="config-title">
{{ translateIfKey(itemMeta?.description) || itemKey }}
{{ templateItemText(entry.__template_key, itemKey, 'description', itemMeta?.description) || itemKey }}
</v-list-item-title>
<v-list-item-subtitle class="config-hint" v-if="itemMeta?.hint">
{{ translateIfKey(itemMeta.hint) }}
{{ templateItemText(entry.__template_key, itemKey, 'hint', itemMeta.hint) }}
</v-list-item-subtitle>
</div>
<div v-for="(childMeta, childKey, childIndex) in itemMeta.items" :key="childKey">
@@ -94,10 +94,10 @@
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ translateIfKey(childMeta?.description) || childKey }}
{{ templateItemText(entry.__template_key, `${itemKey}.${childKey}`, 'description', childMeta?.description) || childKey }}
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
{{ translateIfKey(childMeta?.hint) }}
{{ templateItemText(entry.__template_key, `${itemKey}.${childKey}`, 'hint', childMeta?.hint) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -105,6 +105,9 @@
<ConfigItemRenderer
v-model="entry[itemKey][childKey]"
:item-meta="childMeta"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="templateItemPath(entry.__template_key, `${itemKey}.${childKey}`)"
/>
</v-col>
</v-row>
@@ -122,11 +125,11 @@
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
<span v-if="itemMeta?.description">{{ translateIfKey(itemMeta?.description) }} <span class="property-key">({{ itemKey }})</span></span>
<span v-if="itemMeta?.description">{{ templateItemText(entry.__template_key, itemKey, 'description', itemMeta?.description) }} <span class="property-key">({{ itemKey }})</span></span>
<span v-else>{{ itemKey }}</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
{{ translateIfKey(itemMeta?.hint) }}
{{ templateItemText(entry.__template_key, itemKey, 'hint', itemMeta?.hint) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -134,6 +137,9 @@
<ConfigItemRenderer
v-model="entry[itemKey]"
:item-meta="itemMeta"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="templateItemPath(entry.__template_key, itemKey)"
/>
</v-col>
</v-row>
@@ -153,7 +159,8 @@
<script setup>
import { computed, ref, watch } from 'vue'
import ConfigItemRenderer from './ConfigItemRenderer.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useI18n } from '@/i18n/composables'
import { useConfigTextResolver } from '@/composables/useConfigTextResolver'
const props = defineProps({
modelValue: {
@@ -163,12 +170,24 @@ const props = defineProps({
templates: {
type: Object,
default: () => ({})
},
pluginName: {
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
configPath: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { resolveConfigText } = useConfigTextResolver(props)
const expandedEntries = ref({})
@@ -188,20 +207,31 @@ const defaultValueMap = {
const templateOptions = computed(() => {
return Object.entries(props.templates || {}).map(([value, meta]) => ({
label: meta?.name || value,
label: templateText(value, 'name', meta?.name || value),
value,
hint: meta?.hint || meta?.description || ''
hint: templateText(value, 'hint', meta?.hint || meta?.description || '')
}))
})
function templateLabel(key) {
if (!key) return t('core.common.templateList.unknownTemplate') || '未指定模板'
return translateIfKey(props.templates?.[key]?.name || key)
return templateText(key, 'name', props.templates?.[key]?.name || key)
}
function translateIfKey(value) {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
function templatePath(templateKey) {
return props.configPath ? `${props.configPath}.templates.${templateKey}` : `templates.${templateKey}`
}
function templateItemPath(templateKey, itemPath) {
return `${templatePath(templateKey)}.${itemPath}`
}
function templateText(templateKey, attr, fallback) {
return resolveConfigText(templatePath(templateKey), attr, fallback)
}
function templateItemText(templateKey, itemPath, attr, fallback) {
return resolveConfigText(templateItemPath(templateKey, itemPath), attr, fallback)
}
function buildDefaults(itemsMeta = {}) {

View File

@@ -0,0 +1,33 @@
import { useModuleI18n } from '@/i18n/composables'
import { usePluginI18n } from '@/utils/pluginI18n'
export function useConfigTextResolver(props = {}) {
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { configText } = usePluginI18n()
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
}
const hasPluginI18n = () => {
return Boolean(
props.pluginName
&& props.pluginI18n
&& Object.keys(props.pluginI18n).length > 0,
)
}
const resolveConfigText = (path, attr, fallback) => {
const fallbackText = translateIfKey(fallback) || ''
if (!hasPluginI18n()) {
return fallbackText
}
return configText(props.pluginI18n, path, attr, fallbackText)
}
return {
translateIfKey,
resolveConfigText,
}
}

View File

@@ -52,6 +52,8 @@
"selectFile": "Select File",
"refresh": "Refresh",
"updateAll": "Update All",
"pin": "Pin",
"unpin": "Unpin",
"deleteSource": "Delete Source",
"reshuffle": "Shuffle Again"
},
@@ -59,9 +61,10 @@
"enabled": "Enabled",
"disabled": "Disabled",
"system": "System",
"loading": "Loading...",
"installed": "Installed",
"unknown": "Unknown"
"loading": "Loading...",
"installed": "Installed",
"unknown": "Unknown",
"incompatible": "Incompatible"
},
"tooltips": {
"enable": "Click to Enable",
@@ -107,9 +110,14 @@
},
"info": {
"title": "Info",
"version": "Version",
"author": "Author",
"category": "Category",
"authorWebsite": "Author Website",
"stars": "Stars",
"tags": "Tags",
"astrbotVersion": "AstrBot Version Requirement",
"supportPlatforms": "Supported Platforms",
"authorWebsite": "Author Website",
"repository": "Repository"
}
},
@@ -150,8 +158,6 @@
"sourceExists": "This source already exists",
"installPlugin": "Install Plugin",
"randomPlugins": "🎲 Random Plugins",
"showRandomPlugins": "Show Random Plugins",
"hideRandomPlugins": "Hide Random Plugins",
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
},
"sort": {
@@ -194,7 +200,10 @@
"title": "Install Extension",
"fromFile": "Install from File",
"fromUrl": "Install from URL",
"supportPlatformsCount": "Supports {count} Platforms"
"supportPlatformsCount": "Supports {count} Platforms",
"sectionTitle": "Install",
"downloadSource": "The plugin package will be installed from:",
"githubSecurityWarning": "AstrBot cannot guarantee the safety of plugins downloaded from GitHub. Install only if you trust the plugin source."
},
"danger_warning": {
"title": "Dangerous Plugin Warning",

View File

@@ -52,6 +52,8 @@
"selectFile": "Выбрать файл",
"refresh": "Обновить",
"updateAll": "Обновить все",
"pin": "Закрепить",
"unpin": "Открепить",
"deleteSource": "Удалить источник",
"reshuffle": "Мне повезет!"
},
@@ -61,7 +63,8 @@
"system": "Системный",
"loading": "Загрузка...",
"installed": "Установлен",
"unknown": "Неизвестно"
"unknown": "Неизвестно",
"incompatible": "Несовместимо"
},
"tooltips": {
"enable": "Включить",
@@ -107,8 +110,13 @@
},
"info": {
"title": "Информация",
"version": "Версия",
"author": "Автор",
"category": "Категория",
"stars": "Звезды",
"tags": "Теги",
"astrbotVersion": "Требуемая версия AstrBot",
"supportPlatforms": "Поддерживаемые платформы",
"authorWebsite": "Сайт автора",
"repository": "Репозиторий"
}
@@ -149,8 +157,6 @@
"sourceExists": "Этот источник уже есть в списке",
"installPlugin": "Установить плагин",
"randomPlugins": "🎲 Случайные плагины",
"showRandomPlugins": "Показать случайные",
"hideRandomPlugins": "Скрыть случайные",
"sourceSafetyWarning": "Даже при использовании источников по умолчанию мы не можем гарантировать 100% безопасность и стабильность сторонних плагинов. Пожалуйста, будьте внимательны."
},
"sort": {
@@ -193,7 +199,10 @@
"title": "Установка плагина",
"fromFile": "Из файла",
"fromUrl": "По ссылке",
"supportPlatformsCount": "Поддерживает платформ: {count}"
"supportPlatformsCount": "Поддерживает платформ: {count}",
"sectionTitle": "Установка",
"downloadSource": "Пакет плагина будет установлен из:",
"githubSecurityWarning": "AstrBot не может гарантировать безопасность плагинов, загруженных с GitHub. Устанавливайте только если доверяете источнику плагина."
},
"danger_warning": {
"title": "Внимание!",

View File

@@ -52,6 +52,8 @@
"selectFile": "选择文件",
"refresh": "刷新",
"updateAll": "更新全部插件",
"pin": "置顶",
"unpin": "取消置顶",
"deleteSource": "删除源",
"reshuffle": "随机一发"
},
@@ -59,9 +61,10 @@
"enabled": "启用",
"disabled": "禁用",
"system": "系统",
"loading": "加载中...",
"installed": "已安装",
"unknown": "未知"
"loading": "加载中...",
"installed": "已安装",
"unknown": "未知",
"incompatible": "不兼容"
},
"tooltips": {
"enable": "点击启用",
@@ -107,9 +110,14 @@
},
"info": {
"title": "信息",
"version": "版本",
"author": "作者",
"category": "类别",
"authorWebsite": "作者网站",
"stars": "Star数",
"tags": "标签",
"astrbotVersion": "AstrBot 版本要求",
"supportPlatforms": "支持平台",
"authorWebsite": "作者网站",
"repository": "仓库"
}
},
@@ -150,8 +158,6 @@
"sourceExists": "该插件源已存在",
"installPlugin": "安装插件",
"randomPlugins": "🎲 随机插件",
"showRandomPlugins": "显示随机插件",
"hideRandomPlugins": "隐藏随机插件",
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
},
"sort": {
@@ -194,7 +200,10 @@
"title": "安装插件",
"fromFile": "从文件安装",
"fromUrl": "从链接安装",
"supportPlatformsCount": "支持 {count} 个平台"
"supportPlatformsCount": "支持 {count} 个平台",
"sectionTitle": "安装",
"downloadSource": "将从以下位置安装插件包体:",
"githubSecurityWarning": "AstrBot 不能保证从 GitHub 上下载的插件的安全性。请确认你信任该插件来源后再安装。"
},
"danger_warning": {
"title": "警告",

View File

@@ -163,8 +163,10 @@ export const useCommonStore = defineStore("common", {
const pluginData = res.data.data[key];
data.push({
...pluginData,
"name": pluginData.name || key, // 优先使用插件数据中的name字段否则使用键名
"desc": pluginData.desc,
"short_desc": pluginData?.short_desc ? pluginData.short_desc : "",
"author": pluginData.author,
"repo": pluginData.repo,
"installed": false,
@@ -175,7 +177,9 @@ export const useCommonStore = defineStore("common", {
"pinned": pluginData?.pinned ? pluginData.pinned : false,
"stars": pluginData?.stars ? pluginData.stars : 0,
"updated_at": pluginData?.updated_at ? pluginData.updated_at : "",
"download_url": pluginData?.download_url ? pluginData.download_url : "",
"display_name": pluginData?.display_name ? pluginData.display_name : "",
"i18n": pluginData?.i18n && typeof pluginData.i18n === 'object' ? pluginData.i18n : {},
"astrbot_version": pluginData?.astrbot_version ? pluginData.astrbot_version : "",
"category": pluginData?.category ? pluginData.category : "",
"support_platforms": Array.isArray(pluginData?.support_platforms)

View File

@@ -0,0 +1,69 @@
import { useI18n } from '@/i18n/composables'
function getLocaleData(i18n, locale) {
if (!i18n || typeof i18n !== 'object' || !locale) return null
return i18n[locale] || null
}
function getByPath(source, key) {
if (!source || typeof source !== 'object' || !key) return undefined
const parts = key.split('.')
let current = source
for (const part of parts) {
if (!current || typeof current !== 'object' || !(part in current)) {
return undefined
}
current = current[part]
}
return current
}
export function resolvePluginI18n(i18n, locale, key, fallback = '') {
const localeData = getLocaleData(i18n, locale)
const value = getByPath(localeData, key)
return value === undefined || value === null ? fallback : value
}
export function usePluginI18n() {
const { locale } = useI18n()
const resolve = (i18n, key, fallback = '') => {
return resolvePluginI18n(i18n, locale.value, key, fallback)
}
const pluginName = (plugin) => {
const fallback = plugin?.display_name?.length ? plugin.display_name : plugin?.name
return resolve(plugin?.i18n, 'metadata.display_name', fallback || '')
}
const pluginDesc = (plugin, fallback = '') => {
return resolve(
plugin?.i18n,
'metadata.desc',
fallback || plugin?.desc || plugin?.description || '',
)
}
const pluginShortDesc = (plugin, fallback = '') => {
return resolve(
plugin?.i18n,
'metadata.short_desc',
fallback || plugin?.short_desc || plugin?.desc || plugin?.description || '',
)
}
const configText = (i18n, path, attr, fallback = '') => {
const key = path ? `config.${path}.${attr}` : `config.${attr}`
return resolve(i18n, key, fallback)
}
return {
locale,
resolve,
pluginName,
pluginDesc,
pluginShortDesc,
configText,
}
}

View File

@@ -83,6 +83,7 @@ export const getPluginSearchFields = (plugin) => {
plugin?.name,
plugin?.trimmedName,
plugin?.display_name,
plugin?.short_desc,
plugin?.desc,
plugin?.author,
plugin?.repo,

View File

@@ -12,8 +12,11 @@ import MarketPluginsTab from "./extension/MarketPluginsTab.vue";
import PluginDetailPage from "./extension/PluginDetailPage.vue";
import { useExtensionPage } from "./extension/useExtensionPage";
import { computed } from "vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { usePluginI18n } from "@/utils/pluginI18n";
const pageState = useExtensionPage();
const { pluginName, pluginDesc } = usePluginI18n();
const {
commonStore,
@@ -124,6 +127,8 @@ const {
reloadPlugin,
viewReadme,
viewChangelog,
openInstallDialog,
closeInstallDialog,
handleInstallPlugin,
confirmDangerInstall,
cancelDangerInstall,
@@ -147,6 +152,9 @@ const {
getPlatformDisplayList,
resolveSelectedInstallPlugin,
selectedInstallPlugin,
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
checkInstallCompatibility,
refreshPluginMarket,
handleLocaleChange,
@@ -158,6 +166,8 @@ const selectedPluginId = computed(() => {
return Array.isArray(pluginId) ? pluginId[0] : pluginId || "";
});
const selectedDetailTab = computed(() => extractTabFromHash(route.hash) || "installed");
const selectedInstalledPlugin = computed(() => {
if (!selectedPluginId.value) return null;
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
@@ -165,23 +175,59 @@ const selectedInstalledPlugin = computed(() => {
});
const selectedMarketPlugin = computed(() => {
const plugin = selectedInstalledPlugin.value;
if (!plugin) return null;
const market = Array.isArray(pluginMarketData.value) ? pluginMarketData.value : [];
const repo = plugin.repo?.toLowerCase();
const installedPlugin = selectedInstalledPlugin.value;
const repo = installedPlugin?.repo?.toLowerCase();
return (
market.find((item) => item.name === selectedPluginId.value) ||
market.find((item) => repo && item.repo?.toLowerCase() === repo) ||
market.find((item) => item.name === plugin.name) ||
null
);
});
const selectedDetailPlugin = computed(() => {
if (selectedDetailTab.value === "market" && selectedMarketPlugin.value) {
return selectedMarketPlugin.value;
}
return selectedInstalledPlugin.value || selectedMarketPlugin.value;
});
const installDialogPluginName = computed(() =>
selectedInstallPlugin.value ? pluginName(selectedInstallPlugin.value) : "",
);
const installDialogPluginDesc = computed(() =>
String(
selectedInstallPlugin.value
? pluginDesc(
selectedInstallPlugin.value,
selectedInstallPlugin.value.desc ||
selectedInstallPlugin.value.description ||
"",
)
: "",
).trim(),
);
const installDialogPluginAuthor = computed(() => {
const author = selectedInstallPlugin.value?.author;
if (Array.isArray(author)) return author.join(", ");
if (author && typeof author === "object") return author.name || "";
return typeof author === "string" ? author.trim() : "";
});
const installDialogPluginLogo = computed(() => {
const logo = selectedInstallPlugin.value?.logo;
return typeof logo === "string" && logo.trim() ? logo : defaultPluginIcon;
});
</script>
<template>
<PluginDetailPage
v-if="selectedPluginId && selectedInstalledPlugin"
:plugin="selectedInstalledPlugin"
v-if="selectedPluginId && selectedDetailPlugin"
:plugin="selectedDetailPlugin"
:market-plugin="selectedMarketPlugin"
:source-tab="selectedDetailTab"
:state="pageState"
/>
@@ -195,10 +241,10 @@ const selectedMarketPlugin = computed(() => {
icon="mdi-arrow-left"
variant="text"
density="comfortable"
@click="router.push({ name: 'Extensions', hash: '#installed' })"
@click="router.push({ name: 'Extensions', hash: `#${selectedDetailTab}` })"
/>
<h2 class="text-h3 mb-0 ml-2">
{{ tm("titles.installedAstrBotPlugins") }}
{{ selectedDetailTab === "market" ? tm("tabs.market") : tm("titles.installedAstrBotPlugins") }}
<v-icon icon="mdi-chevron-right" size="24" class="mx-1" />
{{ selectedPluginId }}
</h2>
@@ -331,6 +377,7 @@ const selectedMarketPlugin = computed(() => {
:iterable="extension_config.config"
:metadataKey="curr_namespace"
:pluginName="curr_namespace"
:pluginI18n="extension_config.i18n"
/>
<p v-else>{{ tm("dialogs.config.noConfig") }}</p>
</div>
@@ -592,26 +639,118 @@ const selectedMarketPlugin = computed(() => {
<div
class="v-card 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>
<v-card-title class="text-h3 pa-4 pb-0 pl-6">
{{ tm("dialogs.install.title") }}
</v-card-title>
<div class="v-card-text">
<v-tabs v-model="uploadTab" color="primary">
<div v-if="selectedMarketInstallPlugin" class="market-install-confirm">
<div class="market-install-confirm__header">
<img
:src="installDialogPluginLogo"
:alt="installDialogPluginName"
class="market-install-confirm__logo"
/>
<div class="market-install-confirm__meta">
<div class="market-install-confirm__name">
{{ installDialogPluginName }}
</div>
<div
v-if="installDialogPluginAuthor"
class="market-install-confirm__author"
>
{{ tm("detail.info.author") }}: {{ installDialogPluginAuthor }}
</div>
</div>
</div>
<v-divider class="my-4" />
<div v-if="installDialogPluginDesc" class="market-install-confirm__section">
<div class="market-install-confirm__section-title">
{{ tm("table.headers.description") }}
</div>
<div class="market-install-confirm__desc">
{{ installDialogPluginDesc }}
</div>
</div>
<div v-if="selectedInstallPlugin" class="mt-4">
<v-chip
v-if="selectedInstallPlugin.astrbot_version"
size="small"
color="secondary"
variant="outlined"
class="mr-2 mb-2"
>
{{ tm("card.status.astrbotVersion") }}:
{{ selectedInstallPlugin.astrbot_version }}
</v-chip>
<v-chip
v-if="normalizePlatformList(selectedInstallPlugin.support_platforms).length"
size="small"
color="info"
variant="outlined"
class="mb-2"
>
{{ tm("card.status.supportPlatform") }}:
{{
getPlatformDisplayList(selectedInstallPlugin.support_platforms).join(
", ",
)
}}
</v-chip>
<v-alert
v-if="
selectedInstallPlugin.astrbot_version &&
installCompat.checked &&
!installCompat.compatible
"
type="warning"
variant="tonal"
density="comfortable"
class="market-install-alert mt-2 mb-3"
>
{{ installCompat.message }}
</v-alert>
</div>
<div
v-if="selectedInstallSourceUrl"
class="market-install-confirm__section-title mt-4"
>
{{ tm("dialogs.install.sectionTitle") }}
</div>
<div
v-if="selectedInstallSourceUrl"
class="market-install-source text-caption text-medium-emphasis mb-3"
>
<div>{{ tm("dialogs.install.downloadSource") }}</div>
<div class="market-install-source__url">
{{ selectedInstallSourceUrl }}
</div>
</div>
<v-alert
v-if="installUsesGithubSource"
type="warning"
variant="tonal"
density="comfortable"
class="market-install-alert mt-4 mb-4"
>
{{ tm("dialogs.install.githubSecurityWarning") }}
</v-alert>
<ProxySelector v-if="!selectedInstallDownloadUrl" class="mt-4" />
</div>
<template v-else>
<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>
</v-tabs>
<v-window v-model="uploadTab" class="mt-4">
<v-window v-model="uploadTab" class="mt-4">
<v-window-item value="file">
<div class="d-flex flex-column align-center justify-center pa-4">
<v-file-input
@@ -702,24 +841,57 @@ const selectedMarketPlugin = computed(() => {
type="warning"
variant="tonal"
density="comfortable"
class="mt-2"
class="market-install-alert mt-2 mb-3"
>
{{ installCompat.message }}
</v-alert>
</div>
<ProxySelector></ProxySelector>
<div
v-if="selectedInstallSourceUrl"
class="market-install-confirm__section-title mt-4"
>
{{ tm("dialogs.install.sectionTitle") }}
</div>
<div
v-if="selectedInstallSourceUrl"
class="market-install-source text-caption text-medium-emphasis mb-3"
>
<div>{{ tm("dialogs.install.downloadSource") }}</div>
<div class="market-install-source__url">
{{ selectedInstallSourceUrl }}
</div>
</div>
<v-alert
v-if="installUsesGithubSource"
type="warning"
variant="tonal"
density="comfortable"
class="market-install-alert mb-4"
>
{{ tm("dialogs.install.githubSecurityWarning") }}
</v-alert>
<ProxySelector v-if="!selectedInstallDownloadUrl"></ProxySelector>
</div>
</v-window-item>
</v-window>
</v-window>
</template>
</div>
<div class="v-card-actions">
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="dialog = false">{{
<v-btn color="grey" variant="text" @click="closeInstallDialog">{{
tm("buttons.cancel")
}}</v-btn>
<v-btn color="primary" variant="text" @click="newExtension">{{
<v-btn
color="primary"
variant="text"
:loading="loading_"
:disabled="loading_"
@click="newExtension"
>{{
tm("buttons.install")
}}</v-btn>
</div>
@@ -928,6 +1100,64 @@ const selectedMarketPlugin = computed(() => {
transform: translateY(-4px) scale(1.05);
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
}
.market-install-confirm {
padding: 8px;
}
.market-install-confirm__header {
align-items: center;
display: flex;
gap: 16px;
}
.market-install-confirm__logo {
border-radius: 14px;
height: 64px;
object-fit: cover;
width: 64px;
}
.market-install-confirm__meta {
min-width: 0;
}
.market-install-confirm__name {
color: rgba(var(--v-theme-on-surface), 0.92);
font-size: 1.25rem;
font-weight: 700;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.market-install-confirm__author,
.market-install-confirm__desc {
color: rgba(var(--v-theme-on-surface), 0.64);
line-height: 1.55;
font-size: 0.875rem;
}
.market-install-confirm__section-title {
color: rgba(var(--v-theme-on-surface), 0.92);
font-weight: 700;
margin-bottom: 8px;
}
.market-install-alert {
font-size: 0.8125rem;
line-height: 1.45;
}
.market-install-source {
min-width: 0;
}
.market-install-source__url {
overflow-x: auto;
white-space: nowrap;
}
</style>
<style>

View File

@@ -27,6 +27,7 @@
<v-row v-else>
<v-col v-for="(platform, index) in config_data.platform || []" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card :item="platform" title-field="id" enabled-field="enable"
variant="outlined"
:bglogo="getPlatformIcon(platform.type || platform.id)" @toggle-enabled="platformStatusChange"
@delete="deletePlatform" @edit="editPlatform">
<template #item-details="{ item }">

View File

@@ -1,6 +1,11 @@
<script setup>
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
import { normalizeTextInput } from "@/utils/inputValue";
import {
readPinnedExtensions,
writePinnedExtensions,
} from "./extensionPreferenceStorage.mjs";
import { computed, ref, watch } from "vue";
const props = defineProps({
state: {
@@ -111,6 +116,7 @@ const {
reloadPlugin,
viewReadme,
viewChangelog,
openInstallDialog,
handleInstallPlugin,
confirmDangerInstall,
cancelDangerInstall,
@@ -148,6 +154,53 @@ const openPluginDetail = (extension) => {
hash: "#installed",
});
};
const pinnedExtensionNames = ref(readPinnedExtensions());
const pinnedExtensionOrder = computed(() => {
const order = new Map();
pinnedExtensionNames.value.forEach((name, index) => {
order.set(name, index);
});
return order;
});
const sortedInstalledPlugins = computed(() => {
const order = pinnedExtensionOrder.value;
return [...filteredPlugins.value].sort((a, b) => {
const aIndex = order.has(a?.name) ? order.get(a.name) : Number.POSITIVE_INFINITY;
const bIndex = order.has(b?.name) ? order.get(b.name) : Number.POSITIVE_INFINITY;
if (aIndex !== bIndex) {
return aIndex - bIndex;
}
return 0;
});
});
watch(
pinnedExtensionNames,
(names) => {
writePinnedExtensions(names);
},
{ deep: true },
);
const isPinnedExtension = (extension) => {
const name = extension?.name;
return !!name && pinnedExtensionOrder.value.has(name);
};
const togglePinnedExtension = (extension) => {
const name = extension?.name;
if (!name) return;
const next = pinnedExtensionNames.value.filter((item) => item !== name);
if (next.length === pinnedExtensionNames.value.length) {
next.unshift(name);
}
pinnedExtensionNames.value = next;
};
</script>
<template>
@@ -252,7 +305,7 @@ const openPluginDetail = (extension) => {
<v-fade-transition hide-on-leave>
<div>
<v-row v-if="filteredPlugins.length === 0" class="text-center">
<v-row v-if="sortedInstalledPlugins.length === 0" class="text-center">
<v-col cols="12" class="pa-2">
<v-icon size="64" color="info" class="mb-4"
>mdi-puzzle-outline</v-icon
@@ -274,9 +327,11 @@ const openPluginDetail = (extension) => {
>
<ExtensionCard
:extension="extension"
:is-pinned="isPinnedExtension(extension)"
class="rounded-lg"
style="background-color: rgb(var(--v-theme-mcpCardBg))"
@click="openPluginDetail(extension)"
@toggle-pin="togglePinnedExtension(extension)"
@configure="openExtensionConfig(extension.name)"
@uninstall="
(ext, options) => uninstallExtension(ext.name, options)
@@ -311,7 +366,7 @@ const openPluginDetail = (extension) => {
z-index: 10000;
border-radius: 16px;
"
@click="dialog = true"
@click="openInstallDialog"
>
<span class="v-btn__overlay"></span>
<span class="v-btn__underlay"></span>

View File

@@ -80,7 +80,6 @@ const {
sortBy,
sortOrder,
randomPluginNames,
showRandomPlugins,
marketCategoryFilter,
marketCategoryItems,
normalizeStr,
@@ -96,7 +95,6 @@ const {
randomPlugins,
shufflePlugins,
refreshRandomPlugins,
toggleRandomPluginsVisibility,
displayItemsPerPage,
totalPages,
paginatedPlugins,
@@ -125,6 +123,7 @@ const {
reloadPlugin,
viewReadme,
viewChangelog,
openInstallDialog,
handleInstallPlugin,
confirmDangerInstall,
cancelDangerInstall,
@@ -175,6 +174,15 @@ const marketCategorySelectItems = computed(() =>
value: item.value,
})),
);
const openMarketPluginDetail = (plugin) => {
if (!plugin?.name) return;
router.push({
name: "ExtensionDetails",
params: { pluginId: plugin.name },
hash: "#market",
});
};
</script>
<template>
@@ -205,20 +213,6 @@ const marketCategorySelectItems = computed(() =>
</template>
</v-tooltip>
<v-btn
color="primary"
variant="tonal"
rounded="md"
class="text-none px-2"
:prepend-icon="showRandomPlugins ? 'mdi-eye-off' : 'mdi-eye'"
@click="toggleRandomPluginsVisibility"
>
{{
showRandomPlugins
? tm("market.hideRandomPlugins")
: tm("market.showRandomPlugins")
}}
</v-btn>
</div>
<v-text-field
@@ -263,7 +257,7 @@ const marketCategorySelectItems = computed(() =>
z-index: 10000;
border-radius: 16px;
"
@click="dialog = true"
@click="openInstallDialog"
>
<span class="v-btn__overlay"></span>
<span class="v-btn__underlay"></span>
@@ -347,6 +341,7 @@ const marketCategorySelectItems = computed(() =>
:default-plugin-icon="defaultPluginIcon"
:show-plugin-full-name="showPluginFullName"
@install="handleInstallPlugin"
@open="openMarketPluginDetail"
/>
</v-col>
</v-row>
@@ -364,7 +359,7 @@ const marketCategorySelectItems = computed(() =>
</div>
<v-expand-transition>
<div v-if="showRandomPlugins">
<div v-if="randomPlugins.length > 0">
<div
class="d-flex align-center mb-2 mt-4"
style="justify-content: space-between; flex-wrap: wrap; gap: 8px"
@@ -397,6 +392,7 @@ const marketCategorySelectItems = computed(() =>
:default-plugin-icon="defaultPluginIcon"
:show-plugin-full-name="showPluginFullName"
@install="handleInstallPlugin"
@open="openMarketPluginDetail"
/>
</v-col>
</v-row>

View File

@@ -4,6 +4,8 @@ import axios from "axios";
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { usePluginI18n } from "@/utils/pluginI18n";
import PluginPlatformChip from "@/components/shared/PluginPlatformChip.vue";
const props = defineProps({
plugin: {
@@ -14,6 +16,10 @@ const props = defineProps({
type: Object,
default: null,
},
sourceTab: {
type: String,
default: "installed",
},
state: {
type: Object,
required: true,
@@ -21,6 +27,7 @@ const props = defineProps({
});
const { tm, router } = props.state;
const { pluginName, pluginDesc: resolvePluginDesc } = usePluginI18n();
const markdown = new MarkdownIt({
html: true,
@@ -75,8 +82,13 @@ const logoLoadFailed = ref(false);
const detailPageRef = ref(null);
const isHeaderStuck = ref(false);
const displayName = computed(() =>
props.plugin.display_name?.length ? props.plugin.display_name : props.plugin.name,
const displayName = computed(() => pluginName(props.plugin));
const detailSourceTab = computed(() =>
props.sourceTab === "market" ? "market" : "installed",
);
const isMarketDetail = computed(() => detailSourceTab.value === "market");
const detailParentTitle = computed(() =>
isMarketDetail.value ? tm("tabs.market") : tm("titles.installedAstrBotPlugins"),
);
const pluginDesc = computed(() => {
@@ -86,7 +98,7 @@ const pluginDesc = computed(() => {
props.marketPlugin?.desc ||
props.marketPlugin?.description ||
"";
return String(desc || "").trim();
return String(resolvePluginDesc(props.plugin, desc) || "").trim();
});
const logoSrc = computed(() => {
@@ -143,10 +155,68 @@ const authorWebsite = computed(() => {
const repoUrl = computed(() => props.plugin.repo || props.marketPlugin?.repo || "");
const firstPresentValue = (...values) =>
values.find(
(value) =>
value !== undefined &&
value !== null &&
value !== "" &&
(!Array.isArray(value) || value.length > 0),
);
const versionDisplay = computed(() =>
String(firstPresentValue(props.plugin.version, props.marketPlugin?.version) || "").trim(),
);
const starsDisplay = computed(() => {
const value = firstPresentValue(props.plugin.stars, props.marketPlugin?.stars);
return value === undefined ? "" : String(value);
});
const tagsDisplay = computed(() => {
const tags = firstPresentValue(props.plugin.tags, props.marketPlugin?.tags);
if (!Array.isArray(tags)) return [];
return tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
});
const astrbotVersionDisplay = computed(() =>
String(
firstPresentValue(props.plugin.astrbot_version, props.marketPlugin?.astrbot_version) || "",
).trim(),
);
const supportPlatformsDisplay = computed(() => {
const platforms = firstPresentValue(
props.plugin.support_platforms,
props.marketPlugin?.support_platforms,
);
if (!Array.isArray(platforms)) return [];
return platforms.filter((platform) => typeof platform === "string");
});
const infoRows = computed(() => {
const rows = [
{ label: tm("detail.info.version"), value: versionDisplay.value, optional: true },
{ label: tm("detail.info.author"), value: authorDisplay.value },
{ label: tm("detail.info.category"), value: categoryDisplay.value, optional: true },
{ label: tm("detail.info.stars"), value: starsDisplay.value, optional: true },
{
label: tm("detail.info.tags"),
value: tagsDisplay.value,
kind: "tags",
optional: true,
},
{
label: tm("detail.info.astrbotVersion"),
value: astrbotVersionDisplay.value,
optional: true,
},
{
label: tm("detail.info.supportPlatforms"),
value: supportPlatformsDisplay.value,
kind: "platforms",
optional: true,
},
{
label: tm("detail.info.authorWebsite"),
value: authorWebsite.value,
@@ -161,12 +231,38 @@ const infoRows = computed(() => {
},
];
return rows.filter((row) => !row.optional || row.value);
return rows.filter(
(row) => !row.optional || (Array.isArray(row.value) ? row.value.length > 0 : row.value),
);
});
const handlers = computed(() =>
Array.isArray(props.plugin.handlers) ? props.plugin.handlers : [],
);
const normalizeHandlerList = (source) => {
if (!source || typeof source !== "object") return [];
if (Array.isArray(source.handlers)) {
return source.handlers.filter((handler) => handler && typeof handler === "object");
}
if (Array.isArray(source.command_handlers)) {
return source.command_handlers.filter(
(handler) => handler && typeof handler === "object",
);
}
if (Array.isArray(source.commands)) {
return source.commands
.filter((command) => command && (typeof command === "string" || typeof command === "object"))
.map((command) =>
typeof command === "string"
? { cmd: command, type: "指令" }
: { type: command.type || "指令", ...command },
);
}
return [];
};
const handlers = computed(() => {
const pluginHandlers = normalizeHandlerList(props.plugin);
if (pluginHandlers.length > 0) return pluginHandlers;
return normalizeHandlerList(props.marketPlugin);
});
const handlerGroupOrder = [
"commands",
@@ -318,7 +414,7 @@ const openExternal = (url) => {
};
const goBack = () => {
router.push({ name: "Extensions", hash: "#installed" });
router.push({ name: "Extensions", hash: `#${detailSourceTab.value}` });
};
const renderMarkdown = (source) => {
@@ -358,6 +454,28 @@ const fetchReadme = async () => {
readmeEmpty.value = false;
renderedReadme.value = "";
if (isMarketDetail.value) {
readmeLoading.value = false;
return;
}
const inlineReadme =
props.plugin.readme ||
props.plugin.README ||
props.plugin.readme_content ||
props.plugin.docs ||
props.marketPlugin?.readme ||
props.marketPlugin?.README ||
props.marketPlugin?.readme_content ||
props.marketPlugin?.docs ||
"";
if (inlineReadme) {
renderedReadme.value = renderMarkdown(inlineReadme);
readmeLoading.value = false;
return;
}
try {
const res = await axios.get("/api/plugin/readme", {
params: { name: props.plugin.name },
@@ -382,6 +500,8 @@ const fetchReadme = async () => {
}
};
const showDocsSection = computed(() => !isMarketDetail.value);
watch(
() => props.plugin?.name,
() => {
@@ -416,7 +536,7 @@ onBeforeUnmount(() => {
>
<h2 class="detail-title">
<button class="detail-title__parent" type="button" @click="goBack">
{{ tm("titles.installedAstrBotPlugins") }}
{{ detailParentTitle }}
</button>
<v-icon icon="mdi-chevron-right" size="24" class="mx-1" />
<span class="detail-title__current">{{ displayName }}</span>
@@ -438,9 +558,9 @@ onBeforeUnmount(() => {
</v-card-text>
</v-card>
<section class="detail-section">
<section v-if="groupedHandlerSections.length" class="detail-section">
<h3 class="detail-section__title">{{ tm("detail.contents") }}</h3>
<div v-if="groupedHandlerSections.length" class="handler-groups">
<div class="handler-groups">
<div
v-for="group in groupedHandlerSections"
:key="group.key"
@@ -539,11 +659,6 @@ onBeforeUnmount(() => {
</v-card>
</div>
</div>
<v-card v-else class="rounded-lg handler-card" variant="outlined">
<v-card-text class="pa-4 text-medium-emphasis">
{{ tm("detail.noContents") }}
</v-card-text>
</v-card>
</section>
<section class="detail-section">
@@ -564,6 +679,21 @@ onBeforeUnmount(() => {
>
{{ row.actionText }}
</v-btn>
<div v-else-if="row.kind === 'tags'" class="detail-tags">
<v-chip
v-for="tag in row.value"
:key="tag"
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="small"
variant="tonal"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
</div>
<div v-else-if="row.kind === 'platforms'" class="detail-tags">
<PluginPlatformChip :platforms="row.value" />
</div>
<button
v-else-if="row.href"
class="detail-link"
@@ -581,7 +711,7 @@ onBeforeUnmount(() => {
</v-card>
</section>
<section class="detail-section">
<section v-if="showDocsSection" class="detail-section">
<h3 class="detail-section__title">{{ tm("detail.docsTitle") }}</h3>
<v-card class="rounded-lg docs-card" variant="outlined">
<v-card-text>
@@ -827,6 +957,12 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.docs-card {
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), 0.9);

View File

@@ -0,0 +1,89 @@
export const PINNED_EXTENSIONS_STORAGE_KEY = "astrbot.pinnedExtensions";
const getStorageForRead = (storageOverride) => {
if (storageOverride === null) {
return null;
}
if (storageOverride !== undefined) {
return typeof storageOverride?.getItem === "function"
? storageOverride
: null;
}
if (typeof window === "undefined") {
return null;
}
try {
const localStorage = window.localStorage ?? null;
return typeof localStorage?.getItem === "function" ? localStorage : null;
} catch {
return null;
}
};
const getStorageForWrite = (storageOverride) => {
if (storageOverride === null) {
return null;
}
if (storageOverride !== undefined) {
return typeof storageOverride?.setItem === "function"
? storageOverride
: null;
}
if (typeof window === "undefined") {
return null;
}
try {
const localStorage = window.localStorage ?? null;
return typeof localStorage?.setItem === "function" ? localStorage : null;
} catch {
return null;
}
};
const normalizePinnedExtensions = (value) => {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
return value
.filter((item) => typeof item === "string" && item.trim().length > 0)
.map((item) => item.trim())
.filter((item) => {
if (seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
};
export const readPinnedExtensions = (storage) => {
const targetStorage = getStorageForRead(storage);
if (!targetStorage) {
return [];
}
try {
const raw = targetStorage.getItem(PINNED_EXTENSIONS_STORAGE_KEY);
return normalizePinnedExtensions(raw ? JSON.parse(raw) : []);
} catch {
return [];
}
};
export const writePinnedExtensions = (names, storage) => {
const targetStorage = getStorageForWrite(storage);
if (!targetStorage) {
return;
}
try {
targetStorage.setItem(
PINNED_EXTENSIONS_STORAGE_KEY,
JSON.stringify(normalizePinnedExtensions(names)),
);
} catch {
// Ignore restricted storage environments.
}
};

View File

@@ -17,36 +17,6 @@ import {
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
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 : {};
@@ -70,6 +40,7 @@ export const useExtensionPage = () => {
const { tm } = useModuleI18n("features/extension");
const router = useRouter();
const route = useRoute();
const marketCompatibilityCache = new Map();
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
@@ -127,6 +98,7 @@ export const useExtensionPage = () => {
const extension_config = reactive({
metadata: {},
config: {},
i18n: {},
});
const pluginMarketData = ref([]);
const loadingDialog = reactive({
@@ -138,6 +110,7 @@ export const useExtensionPage = () => {
const showPluginInfoDialog = ref(false);
const selectedPlugin = ref({});
const curr_namespace = ref("");
const currentConfigPlugin = ref("");
const updatingAll = ref(false);
const readmeDialog = reactive({
@@ -215,15 +188,6 @@ export const useExtensionPage = () => {
const sortOrder = ref("desc"); // desc (降序) or asc (升序)
const randomPluginNames = ref([]);
const marketCategoryFilter = ref("all");
const {
showRandomPlugins,
toggleRandomPluginsVisibility,
collapseRandomPlugins,
} = useRandomPluginsDisplay({
activeTab,
marketSearch,
currentPage,
});
// 插件市场拼音搜索
@@ -854,6 +818,7 @@ export const useExtensionPage = () => {
const openExtensionConfig = async (extension_name) => {
curr_namespace.value = extension_name;
currentConfigPlugin.value = extension_name;
configDialog.value = true;
try {
const res = await axios.get(
@@ -861,6 +826,7 @@ export const useExtensionPage = () => {
);
extension_config.metadata = res.data.data.metadata;
extension_config.config = res.data.data.config;
extension_config.i18n = res.data.data.i18n || {};
} catch (err) {
toast(err, "error");
}
@@ -878,8 +844,10 @@ export const useExtensionPage = () => {
toast(res.data.message, "error");
}
configDialog.value = false;
currentConfigPlugin.value = "";
extension_config.metadata = {};
extension_config.config = {};
extension_config.i18n = {};
getExtensions();
} catch (err) {
toast(err, "error");
@@ -917,6 +885,52 @@ export const useExtensionPage = () => {
changelogDialog.repoUrl = plugin.repo;
changelogDialog.show = true;
};
const resetInstallDialogState = () => {
selectedMarketInstallPlugin.value = null;
extension_url.value = "";
upload_file.value = null;
uploadTab.value = "file";
installCompat.checked = false;
installCompat.compatible = true;
installCompat.message = "";
};
const openInstallDialog = () => {
resetInstallDialogState();
dialog.value = true;
};
const closeInstallDialog = () => {
dialog.value = false;
resetInstallDialogState();
};
const normalizeInstallUrl = (value) =>
String(value || "").trim().replace(/\/+$/, "");
const isGithubRepoUrl = (value) =>
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?(?:\/tree\/[^/\s]+)?$/i.test(
normalizeInstallUrl(value),
);
const selectedInstallDownloadUrl = computed(() => {
const plugin = selectedInstallPlugin.value;
const downloadUrl = String(plugin?.download_url || "").trim();
if (!downloadUrl) return "";
if (normalizeInstallUrl(plugin?.repo) !== normalizeInstallUrl(extension_url.value)) {
return "";
}
return downloadUrl;
});
const selectedInstallSourceUrl = computed(
() => selectedInstallDownloadUrl.value || String(extension_url.value || "").trim(),
);
const installUsesGithubSource = computed(
() => !selectedInstallDownloadUrl.value && isGithubRepoUrl(extension_url.value),
);
// 为表格视图创建一个处理安装插件的函数
const handleInstallPlugin = async (plugin) => {
@@ -926,6 +940,7 @@ export const useExtensionPage = () => {
} else {
selectedMarketInstallPlugin.value = plugin;
extension_url.value = plugin.repo;
upload_file.value = null;
dialog.value = true;
uploadTab.value = "url";
}
@@ -936,6 +951,7 @@ export const useExtensionPage = () => {
if (selectedDangerPlugin.value) {
selectedMarketInstallPlugin.value = selectedDangerPlugin.value;
extension_url.value = selectedDangerPlugin.value.repo;
upload_file.value = null;
dialog.value = true;
uploadTab.value = "url";
}
@@ -1177,6 +1193,58 @@ export const useExtensionPage = () => {
}
pluginMarketData.value = notInstalled.concat(installed);
};
const normalizeAstrBotVersionSpec = (value) => String(value || "").trim();
const checkAstrBotVersionCompatibility = async (versionSpec) => {
const normalizedSpec = normalizeAstrBotVersionSpec(versionSpec);
if (!normalizedSpec) {
return { checked: false, compatible: true, message: "" };
}
if (marketCompatibilityCache.has(normalizedSpec)) {
return marketCompatibilityCache.get(normalizedSpec);
}
try {
const res = await axios.post("/api/plugin/check-compat", {
astrbot_version: normalizedSpec,
});
const result = {
checked: res.data.status === "ok",
compatible:
res.data.status === "ok" ? !!res.data.data?.compatible : true,
message: res.data.data?.message || "",
};
marketCompatibilityCache.set(normalizedSpec, result);
return result;
} catch (err) {
console.debug("Failed to check plugin compatibility:", err);
const result = { checked: false, compatible: true, message: "" };
marketCompatibilityCache.set(normalizedSpec, result);
return result;
}
};
const annotateMarketCompatibility = async () => {
const specs = [
...new Set(
pluginMarketData.value
.map((plugin) => normalizeAstrBotVersionSpec(plugin?.astrbot_version))
.filter(Boolean),
),
];
await Promise.all(specs.map((spec) => checkAstrBotVersionCompatibility(spec)));
pluginMarketData.value.forEach((plugin) => {
const spec = normalizeAstrBotVersionSpec(plugin?.astrbot_version);
const result = spec ? marketCompatibilityCache.get(spec) : null;
plugin.astrbot_compat_checked = !!result?.checked;
plugin.astrbot_compatible = result ? result.compatible : true;
plugin.astrbot_compat_message = result?.message || "";
});
};
const showVersionCompatibilityWarning = (message) => {
versionCompatibilityDialog.message = message;
@@ -1200,23 +1268,19 @@ export const useExtensionPage = () => {
versionCompatibilityDialog.show = false;
};
const handleInstallResponse = async (resData, { toastStatus = false } = {}) => {
const handleInstallResponse = async (resData) => {
if (
resData.status === "warning" &&
resData.data?.warning_type === "astrbot_version_incompatible"
) {
onLoadingDialogResult(2, resData.message, -1);
toast(resData.message, "warning");
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);
toast(resData.message, "error");
await refreshExtensionsAfterInstallFailure();
return false;
}
@@ -1238,7 +1302,8 @@ export const useExtensionPage = () => {
return axios.post("/api/plugin/install", {
url: extension_url.value,
proxy: getSelectedGitHubProxy(),
download_url: selectedInstallDownloadUrl.value,
proxy: selectedInstallDownloadUrl.value ? "" : getSelectedGitHubProxy(),
ignore_version_check: ignoreVersionCheck,
});
};
@@ -1250,8 +1315,9 @@ export const useExtensionPage = () => {
extension_url.value = "";
}
onLoadingDialogResult(1, resData.message);
toast(resData.message, "success");
dialog.value = false;
selectedMarketInstallPlugin.value = null;
await getExtensions();
checkAlreadyInstalled();
@@ -1273,35 +1339,21 @@ export const useExtensionPage = () => {
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",
);
loading_.value = true;
try {
const res = await performInstallRequest({ source, ignoreVersionCheck });
loading_.value = false;
const canContinue = await handleInstallResponse(res.data, {
toastStatus: source === "url",
});
const canContinue = await handleInstallResponse(res.data);
if (!canContinue) return;
await finalizeSuccessfulInstall(res.data, source);
} catch (err) {
loading_.value = false;
const message = resolveErrorMessage(err, tm("messages.installFailed"));
if (source === "url") {
toast(message, "error");
}
onLoadingDialogResult(2, message, -1);
toast(message, "error");
await refreshExtensionsAfterInstallFailure();
}
};
@@ -1366,6 +1418,7 @@ export const useExtensionPage = () => {
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
await annotateMarketCompatibility();
checkUpdate();
refreshRandomPlugins();
currentPage.value = 1; // 重置到第一页
@@ -1407,6 +1460,7 @@ export const useExtensionPage = () => {
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
await annotateMarketCompatibility();
checkUpdate();
refreshRandomPlugins();
} catch (err) {
@@ -1453,6 +1507,9 @@ export const useExtensionPage = () => {
installCompat.checked = false;
installCompat.compatible = true;
installCompat.message = "";
if (!dialogOpen) {
selectedMarketInstallPlugin.value = null;
}
return;
}
await checkInstallCompatibility();
@@ -1559,7 +1616,6 @@ export const useExtensionPage = () => {
sortBy,
sortOrder,
randomPluginNames,
showRandomPlugins,
normalizeStr,
toPinyinText,
toInitials,
@@ -1572,8 +1628,6 @@ export const useExtensionPage = () => {
randomPlugins,
shufflePlugins,
refreshRandomPlugins,
toggleRandomPluginsVisibility,
collapseRandomPlugins,
displayItemsPerPage,
totalPages,
paginatedPlugins,
@@ -1605,6 +1659,8 @@ export const useExtensionPage = () => {
reloadPlugin,
viewReadme,
viewChangelog,
openInstallDialog,
closeInstallDialog,
handleInstallPlugin,
confirmDangerInstall,
cancelDangerInstall,
@@ -1628,6 +1684,9 @@ export const useExtensionPage = () => {
getPlatformDisplayList,
resolveSelectedInstallPlugin,
selectedInstallPlugin,
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
checkInstallCompatibility,
refreshPluginMarket,
handleLocaleChange,

View File

@@ -1,6 +1,6 @@
<template>
<v-card class="persona-card" :class="{ 'dragging': isDragging }" rounded="lg" @click="$emit('view')" elevation="0"
draggable="true" @dragstart="handleDragStart" @dragend="handleDragEnd">
<v-card class="persona-card" :class="{ 'dragging': isDragging }" rounded="lg" variant="outlined" @click="$emit('view')"
elevation="0" draggable="true" @dragstart="handleDragStart" @dragend="handleDragEnd">
<v-card-title class="d-flex justify-space-between align-center">
<div class="text-truncate ml-2">{{ persona.persona_id }}</div>
<v-menu offset-y>
@@ -142,9 +142,15 @@ export default defineComponent({
<style scoped>
.persona-card {
background: rgb(var(--v-theme-surface));
height: 100%;
cursor: grab;
transition: all 0.2s ease;
transition: background-color 0.16s ease, opacity 0.2s ease, transform 0.2s ease;
}
.persona-card:hover,
.persona-card:focus-within {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.persona-card:active {

View File

@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
PINNED_EXTENSIONS_STORAGE_KEY,
readPinnedExtensions,
writePinnedExtensions,
} from '../src/views/extension/extensionPreferenceStorage.mjs';
test('readPinnedExtensions uses the legacy pinned extension storage key', () => {
assert.equal(PINNED_EXTENSIONS_STORAGE_KEY, 'astrbot.pinnedExtensions');
});
test('readPinnedExtensions parses stored pinned extension names', () => {
const storage = {
getItem(key) {
return key === PINNED_EXTENSIONS_STORAGE_KEY
? JSON.stringify(['alpha', 'beta', 'alpha', '', 1])
: null;
},
};
assert.deepEqual(readPinnedExtensions(storage), ['alpha', 'beta']);
});
test('readPinnedExtensions returns an empty array when storage access fails', () => {
const storage = {
getItem() {
throw new Error('SecurityError');
},
};
assert.deepEqual(readPinnedExtensions(storage), []);
});
test('writePinnedExtensions stores normalized pinned extension names', () => {
const writes = [];
const storage = {
setItem(key, value) {
writes.push([key, value]);
},
};
writePinnedExtensions(['alpha', 'beta', 'alpha', '', null], storage);
assert.deepEqual(writes, [
[PINNED_EXTENSIONS_STORAGE_KEY, JSON.stringify(['alpha', 'beta'])],
]);
});
test('writePinnedExtensions ignores unavailable storage', () => {
assert.doesNotThrow(() => writePinnedExtensions(['alpha'], null));
assert.doesNotThrow(() => writePinnedExtensions(['alpha'], {}));
});

View File

@@ -191,6 +191,7 @@ export default defineConfig({
{ text: "接收消息事件", link: "/guides/listen-message-event" },
{ text: "发送消息", link: "/guides/send-message" },
{ text: "插件配置", link: "/guides/plugin-config" },
{ text: "插件国际化", link: "/guides/plugin-i18n" },
{ text: "调用 AI", link: "/guides/ai" },
{ text: "存储", link: "/guides/storage" },
{ text: "文转图", link: "/guides/html-to-pic" },
@@ -433,6 +434,7 @@ export default defineConfig({
{ text: "Listen to Message Events", link: "/guides/listen-message-event" },
{ text: "Send Messages", link: "/guides/send-message" },
{ text: "Plugin Configuration", link: "/guides/plugin-config" },
{ text: "Plugin Internationalization", link: "/guides/plugin-i18n" },
{ text: "AI", link: "/guides/ai" },
{ text: "Storage", link: "/guides/storage" },
{ text: "HTML to Image", link: "/guides/html-to-pic" },

View File

@@ -56,6 +56,10 @@ The file content is a `Schema` that represents the configuration. The Schema is
- `editor_theme`: Optional. The theme for the code editor. Options are `vs-light` (default) and `vs-dark`.
- `_special`: Optional. Used to call AstrBot's visualization features for provider selection, persona selection, knowledge base selection, etc. See details below.
### Configuration Internationalization (Optional)
Configuration `description`, `hint`, and select `labels` can follow the WebUI language. See [Plugin Internationalization](./plugin-i18n).
When the code editor is enabled, it looks like this:
![editor_mode](https://files.astrbot.app/docs/source/images/plugin/image-6.png)
@@ -216,4 +220,4 @@ class ConfigPlugin(Star):
## Configuration Updates
When you update the Schema across different versions, AstrBot will recursively inspect the configuration items in the Schema, automatically adding default values for missing items and removing those that no longer exist.
When you update the Schema across different versions, AstrBot will recursively inspect the configuration items in the Schema, automatically adding default values for missing items and removing those that no longer exist.

View File

@@ -0,0 +1,146 @@
# Plugin Internationalization
Plugins can provide `.astrbot-plugin/i18n/*.json` files in their own directory so the WebUI can display plugin names, descriptions, and configuration text in the current language.
## Directory Structure
```text
your_plugin/
metadata.yaml
_conf_schema.json
.astrbot-plugin/
i18n/
zh-CN.json
en-US.json
```
Locale file names use WebUI locales, such as `zh-CN.json` and `en-US.json`. Each file must contain a JSON object.
When the current locale has no translation, a field is missing, or the locale file does not exist, AstrBot falls back to the default text:
- Plugin names, card short descriptions, and descriptions fall back to `display_name`, `short_desc`, and `desc` in `metadata.yaml`.
- Configuration text falls back to `description`, `hint`, and `labels` in `_conf_schema.json`.
## Metadata
`metadata` overrides the plugin name, card short description, and description shown on plugin pages.
```json
{
"metadata": {
"display_name": "Weather Assistant",
"short_desc": "One-line weather lookup.",
"desc": "Query weather and provide travel suggestions."
}
}
```
## Configuration
`config` overrides text from `_conf_schema.json`. The structure is nested by configuration item name.
Example `_conf_schema.json`:
```json
{
"enable": {
"description": "Enable",
"type": "bool",
"hint": "Whether to enable this plugin.",
"default": true
},
"mode": {
"description": "Mode",
"type": "string",
"options": ["fast", "safe"],
"labels": ["Fast", "Safe"]
}
}
```
Corresponding `.astrbot-plugin/i18n/zh-CN.json`:
```json
{
"config": {
"enable": {
"description": "启用",
"hint": "是否启用这个插件。"
},
"mode": {
"description": "模式",
"labels": ["快速", "安全"]
}
}
}
```
`options` are stored configuration values and should usually not be translated. Use `labels` for select display text.
## Nested Configuration
For `object` items in `_conf_schema.json`, translations use the same nested field structure.
```json
{
"config": {
"sub_config": {
"name": {
"description": "Name",
"hint": "The name shown in messages."
}
}
}
}
```
## Template Lists
`template_list` template names and fields can also be translated. Put template names under `templates.<template>.name`, then continue nesting for fields inside the template.
```json
{
"config": {
"rules": {
"description": "Rules",
"templates": {
"default": {
"name": "Default template",
"threshold": {
"description": "Threshold",
"hint": "Triggers the rule after reaching this value."
}
}
}
}
}
}
```
## Complete Example
Here is an English translation example for a real configuration:
```json
{
"metadata": {
"display_name": "HAPI Vibe Coding Remote",
"desc": "Connect to a HAPI service and control coding agent sessions from chat platforms."
},
"config": {
"hapi_endpoint": {
"description": "HAPI service URL",
"hint": "Example: http://localhost:3006"
},
"output_level": {
"description": "SSE delivery level",
"hint": "silence: permission requests only; simple: plain text messages and system events; summary: recent N messages when a task completes; detail: all messages in real time",
"labels": ["Silence", "Simple", "Summary", "Detail"]
}
}
}
```
## Constraints
Plugin internationalization only reads the `.astrbot-plugin/i18n` directory. Locale files must use nested JSON objects; dot-key flat entries are not supported.

View File

@@ -51,6 +51,16 @@ You can add a `logo.png` file in the plugin directory as the plugin's logo. Plea
You can modify (or add) the `display_name` field in the `metadata.yaml` file to serve as the plugin's display name in scenarios like the plugin marketplace, making it easier for users to read.
Plugin display names and descriptions can follow the WebUI language. See [Plugin Internationalization](./guides/plugin-i18n).
### Plugin Short Description (Optional)
You can add a `short_desc` field to `metadata.yaml` as the short description shown on plugin marketplace cards. Keep it to a concise one-sentence summary. If it is not provided, cards fall back to `desc`.
```yaml
short_desc: A one-line summary of your plugin.
```
### Declare Supported Platforms (Optional)
You can add a `support_platforms` field (`list[str]`) to `metadata.yaml` to declare which platform adapters your plugin supports. The WebUI plugin page will display this field.

View File

@@ -56,6 +56,10 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
- `editor_theme`: 可选。代码编辑器的主题,可选值有 `vs-light`(默认), `vs-dark`
- `_special`: 可选。用于调用 AstrBot 提供的可视化提供商选取、人格选取、知识库选取等功能,详见下文。
### 配置项国际化(可选)
配置项的 `description``hint` 和下拉选项 `labels` 支持按 WebUI 语言显示,详见[插件国际化](./plugin-i18n)。
其中,如果启用了代码编辑器,效果如下图所示:
![editor_mode](https://files.astrbot.app/docs/source/images/plugin/image-6.png)

View File

@@ -0,0 +1,146 @@
# 插件国际化
插件可以在自己的目录下提供 `.astrbot-plugin/i18n/*.json`,让 WebUI 根据当前语言显示插件名称、描述和配置项文案。
## 目录结构
```text
your_plugin/
metadata.yaml
_conf_schema.json
.astrbot-plugin/
i18n/
zh-CN.json
en-US.json
```
语言文件名使用 WebUI 的 locale例如 `zh-CN.json``en-US.json`。文件内容必须是 JSON object。
当当前语言没有对应翻译、某个字段缺失或语言文件不存在时AstrBot 会回退到默认文案:
- 插件名称、卡片短描述和描述回退到 `metadata.yaml` 中的 `display_name``short_desc``desc`
- 配置项文案回退到 `_conf_schema.json` 中的 `description``hint``labels`
## 元数据
`metadata` 用于覆盖插件在插件页展示的名称、卡片短描述和描述。
```json
{
"metadata": {
"display_name": "天气助手",
"short_desc": "一句话天气查询。",
"desc": "查询天气并提供出行建议。"
}
}
```
## 配置项
`config` 用于覆盖 `_conf_schema.json` 中的配置文案。结构按配置项名称嵌套。
例如 `_conf_schema.json`
```json
{
"enable": {
"description": "Enable",
"type": "bool",
"hint": "Whether to enable this plugin.",
"default": true
},
"mode": {
"description": "Mode",
"type": "string",
"options": ["fast", "safe"],
"labels": ["Fast", "Safe"]
}
}
```
对应 `.astrbot-plugin/i18n/zh-CN.json`
```json
{
"config": {
"enable": {
"description": "启用",
"hint": "是否启用这个插件。"
},
"mode": {
"description": "模式",
"labels": ["快速", "安全"]
}
}
}
```
`options` 是配置保存值,不建议翻译。下拉框的展示文本请使用 `labels`
## 嵌套配置
如果 `_conf_schema.json` 中有 `object` 类型配置,翻译也按同样的字段结构继续嵌套。
```json
{
"config": {
"sub_config": {
"name": {
"description": "名称",
"hint": "显示在消息中的名称。"
}
}
}
}
```
## 模板列表
`template_list` 的模板名称和模板内字段也可以翻译。模板名称放在 `templates.<模板名>.name`,模板内字段继续往下嵌套。
```json
{
"config": {
"rules": {
"description": "规则",
"templates": {
"default": {
"name": "默认模板",
"threshold": {
"description": "阈值",
"hint": "达到该值后触发规则。"
}
}
}
}
}
}
```
## 完整示例
下面是一个真实配置项的英文翻译示例:
```json
{
"metadata": {
"display_name": "HAPI Vibe Coding Remote",
"desc": "Connect to a HAPI service and control coding agent sessions from chat platforms."
},
"config": {
"hapi_endpoint": {
"description": "HAPI service URL",
"hint": "Example: http://localhost:3006"
},
"output_level": {
"description": "SSE delivery level",
"hint": "silence: permission requests only; simple: plain text messages and system events; summary: recent N messages when a task completes; detail: all messages in real time",
"labels": ["Silence", "Simple", "Summary", "Detail"]
}
}
}
```
## 约束
插件国际化只读取 `.astrbot-plugin/i18n` 目录。语言文件必须使用嵌套 JSON 结构,不支持点号扁平 key。

View File

@@ -53,6 +53,16 @@ git clone 插件仓库地址
可以修改(或添加) `metadata.yaml` 文件中的 `display_name` 字段,作为插件在插件市场等场景中的展示名,以方便用户阅读。
插件展示名和描述支持按 WebUI 语言显示,详见[插件国际化](./guides/plugin-i18n)。
### 插件短描述(可选)
你可以在 `metadata.yaml` 中新增 `short_desc` 字段,作为插件市场卡片上的短描述。它适合写成一句简短介绍;如果没有提供,卡片会回退显示 `desc`
```yaml
short_desc: 一句话介绍你的插件。
```
### 声明支持平台Optional
你可以在 `metadata.yaml` 中新增 `support_platforms` 字段(`list[str]`声明插件支持的平台适配器。WebUI 插件页会展示该字段。

View File

@@ -226,6 +226,14 @@ async def on_message(self, event: AstrMessageEvent):
你可以修改(或添加) `metadata.yaml` 文件中的 `display_name` 字段,作为插件在插件市场等场景中的展示名,以方便用户阅读。
### 插件短描述
你可以在 `metadata.yaml` 中新增 `short_desc` 字段,作为插件市场卡片上的短描述。它适合写成一句简短介绍;如果没有提供,卡片会回退显示 `desc`。
```yaml
short_desc: 一句话介绍你的插件。
```
### 声明支持平台Optional
你可以在 `metadata.yaml` 中新增 `support_platforms` 字段(`list[str]`声明插件支持的平台适配器。WebUI 插件页会展示该字段。

View File

@@ -1,4 +1,5 @@
import asyncio
import json
import os
from pathlib import Path
from typing import Any, cast
@@ -36,6 +37,7 @@ def _write_local_test_plugin(plugin_path: Path, repo_url: str):
"version": "1.0.0",
"author": "AstrBot Team",
"desc": "Local test plugin",
"short_desc": "Local test short description",
}
with open(plugin_path / "metadata.yaml", "w", encoding="utf-8") as f:
yaml.dump(metadata, f)
@@ -52,6 +54,79 @@ def _write_requirements(plugin_path: Path):
f.write("networkx\n")
def test_load_plugin_i18n_reads_locale_files(tmp_path: Path):
plugin_path = tmp_path / "plugin"
i18n_path = plugin_path / ".astrbot-plugin" / "i18n"
i18n_path.mkdir(parents=True)
(i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"desc": "中文描述"}}, ensure_ascii=False),
encoding="utf-8",
)
(i18n_path / "en-US.json").write_text(
json.dumps({"metadata": {"desc": "English description"}}),
encoding="utf-8",
)
(i18n_path / "README.md").write_text("ignored", encoding="utf-8")
assert PluginManager._load_plugin_i18n(str(plugin_path)) == {
"zh-CN": {"metadata": {"desc": "中文描述"}},
"en-US": {"metadata": {"desc": "English description"}},
}
def test_load_plugin_i18n_ignores_legacy_directories(tmp_path: Path):
plugin_path = tmp_path / "plugin"
hidden_legacy_i18n_path = plugin_path / ".i18n"
legacy_i18n_path = plugin_path / "i18n"
hidden_legacy_i18n_path.mkdir(parents=True)
legacy_i18n_path.mkdir()
(hidden_legacy_i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"desc": "隐藏旧目录"}}, ensure_ascii=False),
encoding="utf-8",
)
(legacy_i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"desc": "中文描述"}}, ensure_ascii=False),
encoding="utf-8",
)
assert PluginManager._load_plugin_i18n(str(plugin_path)) == {}
def test_load_plugin_metadata_includes_i18n(tmp_path: Path):
plugin_path = tmp_path / "helloworld"
_write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO)
i18n_path = plugin_path / ".astrbot-plugin" / "i18n"
i18n_path.mkdir(parents=True)
(i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"display_name": "你好世界"}}, ensure_ascii=False),
encoding="utf-8",
)
metadata = PluginManager._load_plugin_metadata(str(plugin_path))
assert metadata is not None
assert metadata.short_desc == "Local test short description"
assert metadata.i18n == {"zh-CN": {"metadata": {"display_name": "你好世界"}}}
def test_loaded_metadata_can_copy_i18n_into_existing_star_metadata(tmp_path: Path):
plugin_path = tmp_path / "helloworld"
_write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO)
i18n_path = plugin_path / ".astrbot-plugin" / "i18n"
i18n_path.mkdir(parents=True)
(i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"desc": "中文描述"}}, ensure_ascii=False),
encoding="utf-8",
)
existing_metadata = star_manager_module.StarMetadata(name="old")
loaded_metadata = PluginManager._load_plugin_metadata(str(plugin_path))
assert loaded_metadata is not None
existing_metadata.i18n = loaded_metadata.i18n
assert existing_metadata.i18n == {"zh-CN": {"metadata": {"desc": "中文描述"}}}
def _clear_module_cache():
"""Clear test-specific modules from sys.modules to allow reloading."""
import sys

View File

@@ -6,6 +6,7 @@ import certifi
import httpx
import pytest
from astrbot.core.star.updator import PluginUpdator
from astrbot.core.zip_updator import RepoZipUpdator
@@ -138,6 +139,44 @@ def fake_async_client_state() -> _FakeAsyncClientState:
return _FakeAsyncClientState()
@pytest.mark.asyncio
async def test_plugin_updator_install_prefers_download_url(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
calls = {}
updator = PluginUpdator()
updator.plugin_store_path = str(tmp_path)
async def fake_download_file(url: str, path: str, timeout: float = 1800.0): # noqa: ARG001
calls["download"] = (url, path)
Path(path).write_bytes(b"zip-data")
async def fail_download_from_repo_url(*args, **kwargs): # noqa: ARG001
raise AssertionError("install should use download_url instead of GitHub")
def fake_unzip_file(zip_path: str, target_dir: str):
calls["unzip"] = (zip_path, target_dir)
monkeypatch.setattr(updator, "_download_file", fake_download_file)
monkeypatch.setattr(updator, "download_from_repo_url", fail_download_from_repo_url)
monkeypatch.setattr(updator, "unzip_file", fake_unzip_file)
plugin_path = await updator.install(
"https://github.com/Owner/plugin-name",
proxy="https://gh-proxy.example",
download_url="https://cdn.example/plugin.zip",
)
expected_path = tmp_path / "plugin_name"
assert plugin_path == str(expected_path)
assert calls["download"] == (
"https://cdn.example/plugin.zip",
str(expected_path) + ".zip",
)
assert calls["unzip"] == (str(expected_path) + ".zip", str(expected_path))
@pytest.mark.asyncio
async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support(
monkeypatch: pytest.MonkeyPatch,
@@ -187,7 +226,9 @@ async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support(
"zipball_url": "https://example.com/astrbot.zip",
}
]
assert fake_async_client_state.requested_urls == ["https://api.soulter.top/releases"]
assert fake_async_client_state.requested_urls == [
"https://api.soulter.top/releases"
]
assert fake_async_client_state.init_kwargs is not None
assert fake_async_client_state.init_kwargs["follow_redirects"] is True
assert fake_async_client_state.init_kwargs["timeout"] == 30.0
@@ -221,7 +262,9 @@ async def test_download_from_repo_url_uses_httpx_stream_for_zip_download(
zip_updator_module,
"download_file",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("download_from_repo_url should not use aiohttp download_file")
AssertionError(
"download_from_repo_url should not use aiohttp download_file"
)
),
raising=False,
)