mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-04 03:30:15 +08:00
Compare commits
9 Commits
feat/plugi
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5360c3b106 | ||
|
|
ac5cb9b529 | ||
|
|
1aacb46289 | ||
|
|
a23350109c | ||
|
|
ffc31b305c | ||
|
|
6f83917336 | ||
|
|
2e49eb8455 | ||
|
|
433836d972 | ||
|
|
d72cb78f37 |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "AstrBot",
|
||||
"desc": "AstrBot's internal plugin, providing some basic capabilities."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "AstrBot",
|
||||
"desc": "AstrBot 的内部插件,提供一些基础能力。"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "内置指令",
|
||||
"desc": "AstrBot 自带插件,提供 /reset、/help、/sid 等内置指令。"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -1,5 +0,0 @@
|
||||
name: session_controller
|
||||
desc: 为插件支持会话控制
|
||||
author: Cvandia & Soulter
|
||||
version: v1.0.1
|
||||
repo: https://astrbot.app
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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:
|
||||
"""提交一个事件到事件队列。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} 成功。")
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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] === '*'
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
33
dashboard/src/composables/useConfigTextResolver.js
Normal file
33
dashboard/src/composables/useConfigTextResolver.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Внимание!",
|
||||
|
||||
@@ -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": "警告",
|
||||
|
||||
@@ -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)
|
||||
|
||||
69
dashboard/src/utils/pluginI18n.js
Normal file
69
dashboard/src/utils/pluginI18n.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ export const getPluginSearchFields = (plugin) => {
|
||||
plugin?.name,
|
||||
plugin?.trimmedName,
|
||||
plugin?.display_name,
|
||||
plugin?.short_desc,
|
||||
plugin?.desc,
|
||||
plugin?.author,
|
||||
plugin?.repo,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
89
dashboard/src/views/extension/extensionPreferenceStorage.mjs
Normal file
89
dashboard/src/views/extension/extensionPreferenceStorage.mjs
Normal 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.
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
54
dashboard/tests/extensionPreferenceStorage.test.mjs
Normal file
54
dashboard/tests/extensionPreferenceStorage.test.mjs
Normal 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'], {}));
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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:
|
||||
|
||||

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

|
||||
|
||||
146
docs/zh/dev/star/guides/plugin-i18n.md
Normal file
146
docs/zh/dev/star/guides/plugin-i18n.md
Normal 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。
|
||||
@@ -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 插件页会展示该字段。
|
||||
|
||||
@@ -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 插件页会展示该字段。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user