mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-03 03:00:15 +08:00
Compare commits
2 Commits
dev
...
feat/page-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b5d6195e | ||
|
|
0bc0ecb17a |
@@ -1657,7 +1657,9 @@ class PluginManager:
|
||||
is_reserved=plugin.reserved,
|
||||
)
|
||||
|
||||
async def update_plugin(self, plugin_name: str, proxy="") -> None:
|
||||
async def update_plugin(
|
||||
self, plugin_name: str, proxy="", download_url: str = ""
|
||||
) -> None:
|
||||
"""升级一个插件"""
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
if not plugin:
|
||||
@@ -1665,7 +1667,7 @@ class PluginManager:
|
||||
if plugin.reserved:
|
||||
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
|
||||
|
||||
await self.updator.update(plugin, proxy=proxy)
|
||||
await self.updator.update(plugin, proxy=proxy, download_url=download_url)
|
||||
if plugin.root_dir_name:
|
||||
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
|
||||
await self._ensure_plugin_requirements(
|
||||
|
||||
@@ -31,11 +31,15 @@ class PluginUpdator(RepoZipUpdator):
|
||||
|
||||
return plugin_path
|
||||
|
||||
async def update(self, plugin: StarMetadata, proxy="") -> str:
|
||||
async def update(
|
||||
self, plugin: StarMetadata, proxy="", download_url: str = ""
|
||||
) -> str:
|
||||
repo_url = plugin.repo
|
||||
|
||||
if not repo_url:
|
||||
raise Exception(f"Plugin {plugin.name} does not specify a repository URL.")
|
||||
if not repo_url and not download_url:
|
||||
raise Exception(
|
||||
f"Plugin {plugin.name} does not specify a repository URL or download URL."
|
||||
)
|
||||
|
||||
if not plugin.root_dir_name:
|
||||
raise Exception(
|
||||
@@ -47,7 +51,13 @@ class PluginUpdator(RepoZipUpdator):
|
||||
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)
|
||||
if download_url:
|
||||
logger.info(
|
||||
f"Downloading plugin update archive for {plugin.name}: {download_url}"
|
||||
)
|
||||
await self._download_file(download_url, plugin_path + ".zip")
|
||||
else:
|
||||
await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy)
|
||||
|
||||
try:
|
||||
remove_dir(plugin_path)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const SELF_ORIGIN = window.location.origin;
|
||||
const pendingRequests = new Map();
|
||||
const sseHandlers = new Map();
|
||||
const contextHandlers = new Set();
|
||||
let requestCounter = 0;
|
||||
let subscriptionCounter = 0;
|
||||
let context = null;
|
||||
@@ -13,7 +14,11 @@
|
||||
});
|
||||
|
||||
function getTargetOrigin() {
|
||||
if (typeof parentOrigin === "string" && parentOrigin && parentOrigin !== "null") {
|
||||
if (
|
||||
typeof parentOrigin === "string" &&
|
||||
parentOrigin &&
|
||||
parentOrigin !== "null"
|
||||
) {
|
||||
return parentOrigin;
|
||||
}
|
||||
if (SELF_ORIGIN !== "null") {
|
||||
@@ -70,6 +75,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getByPath(source, key) {
|
||||
if (!source || typeof source !== "object" || !key) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return String(key)
|
||||
.split(".")
|
||||
.reduce((current, part) => {
|
||||
if (!current || typeof current !== "object" || !(part in current)) {
|
||||
return undefined;
|
||||
}
|
||||
return current[part];
|
||||
}, source);
|
||||
}
|
||||
|
||||
function translate(key, fallback) {
|
||||
const locale = context?.locale;
|
||||
const messages = context?.i18n;
|
||||
const locales = [locale, "zh-CN", "en-US"].filter(Boolean);
|
||||
let value;
|
||||
for (const candidateLocale of locales) {
|
||||
value = getByPath(messages?.[candidateLocale], key);
|
||||
if (value !== undefined && value !== null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return fallback || "";
|
||||
}
|
||||
return typeof value === "string" ? value : String(value);
|
||||
}
|
||||
|
||||
function notifyContextHandlers() {
|
||||
contextHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler(context);
|
||||
} catch (error) {
|
||||
console.error("AstrBotPluginPage context handler failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyContext(nextContext) {
|
||||
if (!nextContext || typeof nextContext !== "object") {
|
||||
return;
|
||||
}
|
||||
context = {
|
||||
...(context || {}),
|
||||
...nextContext,
|
||||
};
|
||||
if (resolveReady) {
|
||||
resolveReady(context);
|
||||
resolveReady = null;
|
||||
}
|
||||
notifyContextHandlers();
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window.parent) {
|
||||
return;
|
||||
@@ -87,11 +149,7 @@
|
||||
}
|
||||
|
||||
if (message.kind === "context") {
|
||||
context = message.context || null;
|
||||
if (resolveReady) {
|
||||
resolveReady(context);
|
||||
resolveReady = null;
|
||||
}
|
||||
applyContext(message.context);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,7 +162,9 @@
|
||||
if (message.ok) {
|
||||
pending.resolve(message.data);
|
||||
} else {
|
||||
pending.reject(new Error(message.error || "Plugin bridge request failed."));
|
||||
pending.reject(
|
||||
new Error(message.error || "Plugin bridge request failed."),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -139,6 +199,30 @@
|
||||
getContext() {
|
||||
return context;
|
||||
},
|
||||
getLocale() {
|
||||
return context?.locale || "zh-CN";
|
||||
},
|
||||
getI18n() {
|
||||
return context?.i18n || {};
|
||||
},
|
||||
t(key, fallback) {
|
||||
return translate(key, fallback);
|
||||
},
|
||||
onContext(handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
contextHandlers.add(handler);
|
||||
if (context) {
|
||||
handler(context);
|
||||
}
|
||||
return () => {
|
||||
contextHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
__setInitialContext(nextContext) {
|
||||
applyContext(nextContext);
|
||||
},
|
||||
apiGet(endpoint, params) {
|
||||
return makeRequest("api:get", { endpoint, params });
|
||||
},
|
||||
|
||||
@@ -189,7 +189,13 @@ class PluginRoute(Route):
|
||||
return await self._plugin_page_error_response(
|
||||
404, "Plugin Page bridge SDK not found"
|
||||
)
|
||||
bridge_js = await self._read_plugin_page_binary(_PLUGIN_PAGE_BRIDGE_FILE)
|
||||
bridge_js = await self._read_plugin_page_text(_PLUGIN_PAGE_BRIDGE_FILE)
|
||||
initial_context = self._get_plugin_page_initial_context()
|
||||
if initial_context:
|
||||
context_json = json.dumps(initial_context, ensure_ascii=False)
|
||||
bridge_js += (
|
||||
f"\n;window.AstrBotPluginPage?.__setInitialContext({context_json});\n"
|
||||
)
|
||||
response = cast(
|
||||
QuartResponse,
|
||||
await make_response(
|
||||
@@ -204,6 +210,82 @@ class PluginRoute(Route):
|
||||
return plugin
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_by_path(source: dict | None, key: str):
|
||||
if not isinstance(source, dict) or not key:
|
||||
return None
|
||||
current = source
|
||||
for part in key.split("."):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
return None
|
||||
current = current[part]
|
||||
return current
|
||||
|
||||
@staticmethod
|
||||
def _get_request_locale(default: str = "zh-CN") -> str:
|
||||
raw_locale = request.headers.get("Accept-Language", "").strip()
|
||||
locale = raw_locale.split(",", 1)[0].split(";", 1)[0].strip()
|
||||
if not locale or len(locale) > 32:
|
||||
return default
|
||||
return locale
|
||||
|
||||
def _get_plugin_page_initial_context(self) -> dict | None:
|
||||
asset_token = request.args.get("asset_token", "").strip()
|
||||
if not asset_token:
|
||||
return None
|
||||
jwt_secret = self.config.get("dashboard", {}).get("jwt_secret")
|
||||
if not isinstance(jwt_secret, str) or not jwt_secret.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(asset_token, jwt_secret, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
if payload.get("token_type") != _PLUGIN_PAGE_ASSET_TOKEN_TYPE:
|
||||
return None
|
||||
|
||||
plugin_name = payload.get("plugin_name")
|
||||
page_name = payload.get("page_name")
|
||||
if not isinstance(plugin_name, str) or not isinstance(page_name, str):
|
||||
return None
|
||||
|
||||
plugin = self._get_plugin_metadata_by_name(plugin_name)
|
||||
if not plugin:
|
||||
return None
|
||||
|
||||
locale = (
|
||||
payload.get("locale")
|
||||
if isinstance(payload.get("locale"), str)
|
||||
else self._get_request_locale()
|
||||
)
|
||||
plugin_i18n = plugin.i18n or {}
|
||||
try:
|
||||
plugin_root = self._get_plugin_root_dir(plugin)
|
||||
fresh_i18n = PluginManager._load_plugin_i18n(str(plugin_root))
|
||||
if fresh_i18n:
|
||||
plugin_i18n = fresh_i18n
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
locale_data = plugin_i18n.get(locale)
|
||||
display_name = (
|
||||
self._get_by_path(locale_data, "metadata.display_name")
|
||||
or plugin.display_name
|
||||
or plugin.name
|
||||
)
|
||||
page_title = (
|
||||
self._get_by_path(locale_data, f"pages.{page_name}.title") or page_name
|
||||
)
|
||||
|
||||
return {
|
||||
"pluginName": plugin.name,
|
||||
"displayName": display_name,
|
||||
"pageName": page_name,
|
||||
"pageTitle": page_title,
|
||||
"locale": locale,
|
||||
"i18n": plugin_i18n,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_plugin_page_path(
|
||||
raw_path: str,
|
||||
@@ -634,6 +716,7 @@ class PluginRoute(Route):
|
||||
page_data = {
|
||||
"name": page.name,
|
||||
"title": page.title,
|
||||
"i18n_key": f"pages.{page.name}",
|
||||
}
|
||||
if include_content_path:
|
||||
asset_token = (
|
||||
@@ -675,6 +758,7 @@ class PluginRoute(Route):
|
||||
"token_type": _PLUGIN_PAGE_ASSET_TOKEN_TYPE,
|
||||
"plugin_name": plugin_name,
|
||||
"page_name": page_name,
|
||||
"locale": self._get_request_locale(),
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=_PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS),
|
||||
}
|
||||
@@ -1285,6 +1369,7 @@ class PluginRoute(Route):
|
||||
"name": page["title"],
|
||||
"title": page["title"],
|
||||
"page_name": page["name"],
|
||||
"i18n_key": page["i18n_key"],
|
||||
"description": "Plugin Page entry",
|
||||
"plugin_name": plugin.name,
|
||||
}
|
||||
@@ -1708,9 +1793,12 @@ class PluginRoute(Route):
|
||||
post_data = await request.get_json()
|
||||
plugin_name = post_data["name"]
|
||||
proxy: str = post_data.get("proxy", None)
|
||||
download_url = str(post_data.get("download_url") or "").strip()
|
||||
try:
|
||||
logger.info(f"正在更新插件 {plugin_name}")
|
||||
await self.plugin_manager.update_plugin(plugin_name, proxy)
|
||||
await self.plugin_manager.update_plugin(
|
||||
plugin_name, proxy, download_url=download_url
|
||||
)
|
||||
# self.core_lifecycle.restart()
|
||||
await self.plugin_manager.reload(plugin_name)
|
||||
await self._sync_skills_after_plugin_change()
|
||||
@@ -1731,9 +1819,12 @@ class PluginRoute(Route):
|
||||
post_data = await request.get_json()
|
||||
plugin_names: list[str] = post_data.get("names") or []
|
||||
proxy: str = post_data.get("proxy", "")
|
||||
download_urls: dict[str, str] = post_data.get("download_urls") or {}
|
||||
|
||||
if not isinstance(plugin_names, list) or not plugin_names:
|
||||
return Response().error("插件列表不能为空").__dict__
|
||||
if not isinstance(download_urls, dict):
|
||||
download_urls = {}
|
||||
|
||||
results = []
|
||||
sem = asyncio.Semaphore(PLUGIN_UPDATE_CONCURRENCY)
|
||||
@@ -1742,7 +1833,10 @@ class PluginRoute(Route):
|
||||
async with sem:
|
||||
try:
|
||||
logger.info(f"批量更新插件 {name}")
|
||||
await self.plugin_manager.update_plugin(name, proxy)
|
||||
download_url = str(download_urls.get(name) or "").strip()
|
||||
await self.plugin_manager.update_plugin(
|
||||
name, proxy, download_url=download_url
|
||||
)
|
||||
return {"name": name, "status": "ok", "message": "更新成功"}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -104,6 +104,8 @@
|
||||
"notFound": "Plugin not found",
|
||||
"docsTitle": "Documentation",
|
||||
"docsEmpty": "No documentation",
|
||||
"changelogTitle": "Changelog",
|
||||
"changelogEmpty": "No changelog",
|
||||
"handlerGroups": {
|
||||
"page": "Pages",
|
||||
"skill": "Skills",
|
||||
@@ -213,6 +215,12 @@
|
||||
"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."
|
||||
},
|
||||
"update": {
|
||||
"title": "Update Extension",
|
||||
"sectionTitle": "Update",
|
||||
"downloadSource": "The update package will be installed from:",
|
||||
"confirm": "Confirm Update"
|
||||
},
|
||||
"danger_warning": {
|
||||
"title": "Dangerous Plugin Warning",
|
||||
"message": "This plugin has been flagged as containing security risks, including unsafe code or functionalities that may cause system malfunctions or data loss. Do you wish to proceed with the installation?",
|
||||
|
||||
@@ -104,6 +104,8 @@
|
||||
"notFound": "Плагин не найден",
|
||||
"docsTitle": "Документация",
|
||||
"docsEmpty": "Документация отсутствует",
|
||||
"changelogTitle": "Журнал изменений",
|
||||
"changelogEmpty": "Журнал изменений отсутствует",
|
||||
"handlerGroups": {
|
||||
"page": "Pages",
|
||||
"skill": "Skills",
|
||||
@@ -212,6 +214,12 @@
|
||||
"downloadSource": "Пакет плагина будет установлен из:",
|
||||
"githubSecurityWarning": "AstrBot не может гарантировать безопасность плагинов, загруженных с GitHub. Устанавливайте только если доверяете источнику плагина."
|
||||
},
|
||||
"update": {
|
||||
"title": "Обновление плагина",
|
||||
"sectionTitle": "Обновление",
|
||||
"downloadSource": "Пакет обновления будет установлен из:",
|
||||
"confirm": "Подтвердить обновление"
|
||||
},
|
||||
"danger_warning": {
|
||||
"title": "Внимание!",
|
||||
"message": "Этот плагин может содержать небезопасный код или функции, которые могут привести к нестабильности системы или потере данных. Вы уверены, что хотите продолжить установку?",
|
||||
|
||||
@@ -104,6 +104,8 @@
|
||||
"notFound": "未找到该插件",
|
||||
"docsTitle": "文档",
|
||||
"docsEmpty": "暂无文档",
|
||||
"changelogTitle": "更新日志",
|
||||
"changelogEmpty": "暂无更新日志",
|
||||
"handlerGroups": {
|
||||
"page": "页面",
|
||||
"skill": "技能",
|
||||
@@ -213,6 +215,12 @@
|
||||
"downloadSource": "将从以下位置安装插件包体:",
|
||||
"githubSecurityWarning": "AstrBot 不能保证从 GitHub 上下载的插件的安全性。请确认你信任该插件来源后再安装。"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新插件",
|
||||
"sectionTitle": "更新",
|
||||
"downloadSource": "将从以下位置安装更新包体:",
|
||||
"confirm": "确认更新"
|
||||
},
|
||||
"danger_warning": {
|
||||
"title": "警告",
|
||||
"message": "该插件可能包含不安全的代码或功能,可能导致系统异常或数据损失等。请确认是否继续安装?",
|
||||
|
||||
@@ -1,62 +1,106 @@
|
||||
import { useI18n } from '@/i18n/composables'
|
||||
import { useI18n } from "@/i18n/composables";
|
||||
|
||||
function getLocaleData(i18n, locale) {
|
||||
if (!i18n || typeof i18n !== 'object' || !locale) return null
|
||||
return i18n[locale] || null
|
||||
if (!i18n || typeof i18n !== "object" || !locale) return null;
|
||||
return i18n[locale] || null;
|
||||
}
|
||||
|
||||
function getByPath(source, key) {
|
||||
if (!source || typeof source !== 'object' || !key) return undefined
|
||||
if (!source || typeof source !== "object" || !key) return undefined;
|
||||
|
||||
const parts = key.split('.')
|
||||
let current = source
|
||||
const parts = key.split(".");
|
||||
let current = source;
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object' || !(part in current)) {
|
||||
return undefined
|
||||
if (!current || typeof current !== "object" || !(part in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part]
|
||||
current = current[part];
|
||||
}
|
||||
return current
|
||||
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 resolvePluginI18n(i18n, locale, key, fallback = "") {
|
||||
const localeData = getLocaleData(i18n, locale);
|
||||
const value = getByPath(localeData, key);
|
||||
return value === undefined || value === null ? fallback : value;
|
||||
}
|
||||
|
||||
function getPluginPageI18nBase(page) {
|
||||
if (page && typeof page === "object") {
|
||||
if (typeof page.i18n_key === "string" && page.i18n_key.trim()) {
|
||||
return page.i18n_key.trim();
|
||||
}
|
||||
const pageName = page.page_name || page.name;
|
||||
return pageName ? `pages.${pageName}` : "";
|
||||
}
|
||||
|
||||
return page ? `pages.${page}` : "";
|
||||
}
|
||||
|
||||
export function usePluginI18n() {
|
||||
const { locale } = useI18n()
|
||||
const { locale } = useI18n();
|
||||
|
||||
const resolve = (i18n, key, fallback = '') => {
|
||||
return resolvePluginI18n(i18n, locale.value, key, fallback)
|
||||
}
|
||||
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 fallback = plugin?.display_name?.length
|
||||
? plugin.display_name
|
||||
: plugin?.name;
|
||||
return resolve(plugin?.i18n, "metadata.display_name", fallback || "");
|
||||
};
|
||||
|
||||
const pluginDesc = (plugin, fallback = '') => {
|
||||
const pluginDesc = (plugin, fallback = "") => {
|
||||
return resolve(
|
||||
plugin?.i18n,
|
||||
'metadata.desc',
|
||||
fallback || plugin?.desc || plugin?.description || '',
|
||||
)
|
||||
}
|
||||
"metadata.desc",
|
||||
fallback || plugin?.desc || plugin?.description || "",
|
||||
);
|
||||
};
|
||||
|
||||
const pluginShortDesc = (plugin, fallback = '') => {
|
||||
const pluginShortDesc = (plugin, fallback = "") => {
|
||||
return resolve(
|
||||
plugin?.i18n,
|
||||
'metadata.short_desc',
|
||||
fallback || plugin?.short_desc || plugin?.desc || plugin?.description || '',
|
||||
)
|
||||
}
|
||||
"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)
|
||||
}
|
||||
const pluginPageText = (plugin, page, attr, fallback = "") => {
|
||||
const base = getPluginPageI18nBase(page);
|
||||
if (!base || !attr) {
|
||||
return fallback;
|
||||
}
|
||||
return resolve(plugin?.i18n, `${base}.${attr}`, fallback);
|
||||
};
|
||||
|
||||
const pluginPageTitle = (plugin, page, fallback = "") => {
|
||||
const pageFallback =
|
||||
fallback ||
|
||||
(page && typeof page === "object"
|
||||
? page.title || page.name || page.page_name
|
||||
: page) ||
|
||||
"";
|
||||
return pluginPageText(plugin, page, "title", pageFallback);
|
||||
};
|
||||
|
||||
const pluginPageDescription = (plugin, page, fallback = "") => {
|
||||
const pageFallback =
|
||||
fallback ||
|
||||
(page && typeof page === "object" ? page.description || page.desc : "") ||
|
||||
"";
|
||||
return pluginPageText(plugin, page, "description", pageFallback);
|
||||
};
|
||||
|
||||
const configText = (i18n, path, attr, fallback = "") => {
|
||||
const key = path ? `config.${path}.${attr}` : `config.${attr}`;
|
||||
return resolve(i18n, key, fallback);
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
@@ -64,6 +108,9 @@ export function usePluginI18n() {
|
||||
pluginName,
|
||||
pluginDesc,
|
||||
pluginShortDesc,
|
||||
pluginPageText,
|
||||
pluginPageTitle,
|
||||
pluginPageDescription,
|
||||
configText,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ const {
|
||||
updatingAll,
|
||||
readmeDialog,
|
||||
forceUpdateDialog,
|
||||
updateConfirmDialog,
|
||||
updateAllConfirmDialog,
|
||||
changelogDialog,
|
||||
getInitialListViewMode,
|
||||
@@ -111,6 +112,8 @@ const {
|
||||
uninstallExtension,
|
||||
handleUninstallConfirm,
|
||||
updateExtension,
|
||||
closeUpdateConfirmDialog,
|
||||
confirmUpdatePlugin,
|
||||
showUpdateAllConfirm,
|
||||
confirmUpdateAll,
|
||||
cancelUpdateAll,
|
||||
@@ -152,6 +155,11 @@ const {
|
||||
selectedInstallDownloadUrl,
|
||||
selectedInstallSourceUrl,
|
||||
installUsesGithubSource,
|
||||
selectedUpdateExtension,
|
||||
selectedUpdateMarketPlugin,
|
||||
selectedUpdateDownloadUrl,
|
||||
selectedUpdateSourceUrl,
|
||||
updateUsesGithubSource,
|
||||
checkInstallCompatibility,
|
||||
refreshPluginMarket,
|
||||
handleLocaleChange,
|
||||
@@ -221,6 +229,28 @@ const installDialogPluginLogo = computed(() => {
|
||||
const logo = selectedInstallPlugin.value?.logo;
|
||||
return typeof logo === "string" && logo.trim() ? logo : defaultPluginIcon;
|
||||
});
|
||||
|
||||
const updateDialogPlugin = computed(
|
||||
() => selectedUpdateMarketPlugin.value || selectedUpdateExtension.value,
|
||||
);
|
||||
|
||||
const updateDialogPluginName = computed(() =>
|
||||
updateDialogPlugin.value ? pluginName(updateDialogPlugin.value) : "",
|
||||
);
|
||||
|
||||
const updateDialogCurrentVersion = computed(() =>
|
||||
String(selectedUpdateExtension.value?.version || "").trim(),
|
||||
);
|
||||
|
||||
const updateDialogTargetVersion = computed(() =>
|
||||
String(selectedUpdateMarketPlugin.value?.version || "").trim(),
|
||||
);
|
||||
|
||||
const updateDialogPluginLogo = computed(() => {
|
||||
const logo =
|
||||
selectedUpdateMarketPlugin.value?.logo || selectedUpdateExtension.value?.logo;
|
||||
return typeof logo === "string" && logo.trim() ? logo : defaultPluginIcon;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1052,6 +1082,84 @@ const installDialogPluginLogo = computed(() => {
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Update plugin confirmation dialog -->
|
||||
<v-dialog v-model="updateConfirmDialog.show" width="500">
|
||||
<v-card class="rounded-lg">
|
||||
<v-card-title class="text-h3 pa-4 pb-0 pl-6">
|
||||
{{ tm("dialogs.update.title") }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="market-install-confirm">
|
||||
<div class="market-install-confirm__header">
|
||||
<img
|
||||
:src="updateDialogPluginLogo"
|
||||
:alt="updateDialogPluginName"
|
||||
class="market-install-confirm__logo"
|
||||
/>
|
||||
<div class="market-install-confirm__meta">
|
||||
<div class="market-install-confirm__name">
|
||||
{{ updateDialogPluginName }}
|
||||
</div>
|
||||
<div
|
||||
v-if="updateDialogCurrentVersion || updateDialogTargetVersion"
|
||||
class="market-install-confirm__author"
|
||||
>
|
||||
<template v-if="updateDialogTargetVersion">
|
||||
{{ updateDialogCurrentVersion || tm("status.unknown") }}
|
||||
<v-icon icon="mdi-arrow-right" size="14" class="mx-1" />
|
||||
{{ updateDialogTargetVersion }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ tm("detail.info.version") }}:
|
||||
{{ updateDialogCurrentVersion || tm("status.unknown") }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<div
|
||||
v-if="selectedUpdateSourceUrl"
|
||||
class="market-install-confirm__section-title"
|
||||
>
|
||||
{{ tm("dialogs.update.sectionTitle") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedUpdateSourceUrl"
|
||||
class="market-install-source text-caption text-medium-emphasis mb-3"
|
||||
>
|
||||
<div>{{ tm("dialogs.update.downloadSource") }}</div>
|
||||
<div class="market-install-source__url">
|
||||
{{ selectedUpdateSourceUrl }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="updateUsesGithubSource"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
class="market-install-alert mt-4 mb-4"
|
||||
>
|
||||
{{ tm("dialogs.install.githubSecurityWarning") }}
|
||||
</v-alert>
|
||||
|
||||
<ProxySelector v-if="!selectedUpdateDownloadUrl" class="mt-4" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="closeUpdateConfirmDialog">
|
||||
{{ tm("buttons.cancel") }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="confirmUpdatePlugin">
|
||||
{{ tm("dialogs.update.confirm") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 强制更新确认对话框 -->
|
||||
<v-dialog v-model="forceUpdateDialog.show" max-width="420">
|
||||
<v-card class="rounded-lg">
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<script setup>
|
||||
import axios from "axios";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { usePluginI18n } from "@/utils/pluginI18n";
|
||||
|
||||
const BRIDGE_CHANNEL = "astrbot-plugin-page";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { tm } = useModuleI18n("features/extension");
|
||||
const {
|
||||
locale,
|
||||
pluginName: pluginDisplayName,
|
||||
pluginPageTitle,
|
||||
} = usePluginI18n();
|
||||
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
@@ -22,8 +28,23 @@ let iframeMessageOrigin = null;
|
||||
|
||||
const pluginName = computed(() => String(route.params.pluginName || ""));
|
||||
const pageName = computed(() => String(route.params.pageName || ""));
|
||||
const localizedPageTitle = computed(() =>
|
||||
pluginPageTitle(
|
||||
plugin.value,
|
||||
page.value || pageName.value,
|
||||
page.value?.title || pageName.value || tm("buttons.openPages"),
|
||||
),
|
||||
);
|
||||
const getIframeWindow = () => iframeRef.value?.contentWindow || null;
|
||||
|
||||
const toPostMessageData = (value, fallback = null) => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(toRaw(value)));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
@@ -84,12 +105,19 @@ const normalizePluginEndpoint = (endpoint) => {
|
||||
if (!trimmed) {
|
||||
throw new Error("Plugin bridge endpoint cannot be empty.");
|
||||
}
|
||||
if (trimmed.includes("\\") || trimmed.includes("://") || trimmed.includes("?") || trimmed.includes("#")) {
|
||||
if (
|
||||
trimmed.includes("\\") ||
|
||||
trimmed.includes("://") ||
|
||||
trimmed.includes("?") ||
|
||||
trimmed.includes("#")
|
||||
) {
|
||||
throw new Error("Plugin bridge endpoint is invalid.");
|
||||
}
|
||||
|
||||
const segments = trimmed.split("/");
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
if (
|
||||
segments.some((segment) => !segment || segment === "." || segment === "..")
|
||||
) {
|
||||
throw new Error("Plugin bridge endpoint is invalid.");
|
||||
}
|
||||
return segments.map((segment) => encodeURIComponent(segment)).join("/");
|
||||
@@ -114,7 +142,9 @@ const isBridgeUploadFile = (value) => {
|
||||
if (tag === "[object File]" || tag === "[object Blob]") {
|
||||
return true;
|
||||
}
|
||||
return typeof value.arrayBuffer === "function" && typeof value.size === "number";
|
||||
return (
|
||||
typeof value.arrayBuffer === "function" && typeof value.size === "number"
|
||||
);
|
||||
};
|
||||
|
||||
const coerceBridgeUploadFile = async (value, fileName) => {
|
||||
@@ -134,7 +164,9 @@ const coerceBridgeUploadFile = async (value, fileName) => {
|
||||
return new File([buffer], fileName, {
|
||||
type: fileType,
|
||||
lastModified:
|
||||
typeof value.lastModified === "number" ? value.lastModified : Date.now(),
|
||||
typeof value.lastModified === "number"
|
||||
? value.lastModified
|
||||
: Date.now(),
|
||||
});
|
||||
}
|
||||
return new Blob([buffer], { type: fileType });
|
||||
@@ -165,9 +197,11 @@ const sendIframeContext = () => {
|
||||
kind: "context",
|
||||
context: {
|
||||
pluginName: plugin.value.name,
|
||||
displayName: plugin.value.display_name || plugin.value.name,
|
||||
displayName: pluginDisplayName(plugin.value),
|
||||
pageName: page.value.name,
|
||||
pageTitle: page.value.title,
|
||||
pageTitle: localizedPageTitle.value,
|
||||
locale: locale.value,
|
||||
i18n: toPostMessageData(plugin.value.i18n, {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -221,7 +255,9 @@ const handleBridgeRequest = async (message) => {
|
||||
},
|
||||
);
|
||||
if (response.data?.status === "error") {
|
||||
throw new Error(response.data.message || "Plugin upload request failed.");
|
||||
throw new Error(
|
||||
response.data.message || "Plugin upload request failed.",
|
||||
);
|
||||
}
|
||||
sendBridgeResponse(requestId, true, response.data?.data ?? response.data);
|
||||
return;
|
||||
@@ -237,7 +273,9 @@ const handleBridgeRequest = async (message) => {
|
||||
anchor.href = blobUrl;
|
||||
anchor.download =
|
||||
(typeof message.filename === "string" && message.filename) ||
|
||||
parseContentDispositionFilename(response.headers["content-disposition"]);
|
||||
parseContentDispositionFilename(
|
||||
response.headers["content-disposition"],
|
||||
);
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -254,11 +292,16 @@ const handleBridgeRequest = async (message) => {
|
||||
throw new Error("Missing SSE subscription id.");
|
||||
}
|
||||
closeSSEConnection(subscriptionId);
|
||||
const url = new URL(buildPluginApiPath(message.endpoint), window.location.origin);
|
||||
const url = new URL(
|
||||
buildPluginApiPath(message.endpoint),
|
||||
window.location.origin,
|
||||
);
|
||||
Object.entries(message.params || {}).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, String(value));
|
||||
});
|
||||
const eventSource = new EventSource(url.toString(), { withCredentials: true });
|
||||
const eventSource = new EventSource(url.toString(), {
|
||||
withCredentials: true,
|
||||
});
|
||||
sseConnections.set(subscriptionId, eventSource);
|
||||
eventSource.onopen = () => {
|
||||
postToIframe({ kind: "sse_state", subscriptionId, state: "open" });
|
||||
@@ -285,13 +328,19 @@ const handleBridgeRequest = async (message) => {
|
||||
|
||||
if (action === "sse:unsubscribe") {
|
||||
closeSSEConnection(String(message.subscriptionId || ""));
|
||||
sendBridgeResponse(requestId, true, { subscriptionId: message.subscriptionId });
|
||||
sendBridgeResponse(requestId, true, {
|
||||
subscriptionId: message.subscriptionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported plugin bridge action: ${action}`);
|
||||
} catch (error) {
|
||||
sendBridgeResponse(requestId, false, error?.message || "Plugin bridge request failed.");
|
||||
sendBridgeResponse(
|
||||
requestId,
|
||||
false,
|
||||
error?.message || "Plugin bridge request failed.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -386,7 +435,9 @@ const loadPluginPage = async () => {
|
||||
iframeSrc.value = pageEntry.content_path;
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error?.response?.data?.message || error?.message || tm("messages.pluginPageLoadFailed");
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
tm("messages.pluginPageLoadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -402,6 +453,9 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
|
||||
watch([pluginName, pageName], loadPluginPage, { immediate: true });
|
||||
watch(locale, () => {
|
||||
sendIframeContext();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -418,7 +472,7 @@ watch([pluginName, pageName], loadPluginPage, { immediate: true });
|
||||
|
||||
<div>
|
||||
<div class="text-h2 mb-1">
|
||||
{{ page?.title || pageName || tm("buttons.openPages") }}
|
||||
{{ localizedPageTitle }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,12 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const { tm, router } = props.state;
|
||||
const { pluginName, pluginDesc: resolvePluginDesc } = usePluginI18n();
|
||||
const {
|
||||
pluginName,
|
||||
pluginDesc: resolvePluginDesc,
|
||||
pluginPageTitle,
|
||||
pluginPageDescription,
|
||||
} = usePluginI18n();
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
html: true,
|
||||
@@ -84,6 +89,10 @@ const readmeLoading = ref(false);
|
||||
const readmeError = ref("");
|
||||
const readmeEmpty = ref(false);
|
||||
const renderedReadme = ref("");
|
||||
const changelogLoading = ref(false);
|
||||
const changelogError = ref("");
|
||||
const changelogEmpty = ref(false);
|
||||
const renderedChangelog = ref("");
|
||||
const expandedCommandGroups = ref(new Set());
|
||||
const logoLoadFailed = ref(false);
|
||||
const detailPageRef = ref(null);
|
||||
@@ -422,6 +431,16 @@ const getHandlerCommand = (handler) =>
|
||||
).trim();
|
||||
|
||||
const getHandlerDisplayName = (handler, groupKey) => {
|
||||
if (groupKey === "page") {
|
||||
return pluginPageTitle(
|
||||
pluginData.value,
|
||||
handler,
|
||||
handler?.title ||
|
||||
handler?.name ||
|
||||
handler?.page_name ||
|
||||
tm("status.unknown"),
|
||||
);
|
||||
}
|
||||
if (handler?.name) {
|
||||
return handler.name;
|
||||
}
|
||||
@@ -446,10 +465,16 @@ const toggleCommandGroup = (key) => {
|
||||
expandedCommandGroups.value = next;
|
||||
};
|
||||
|
||||
const getComponentDescription = (component) =>
|
||||
String(
|
||||
component?.description || component?.desc || tm("status.unknown"),
|
||||
).trim();
|
||||
const getComponentDescription = (component) => {
|
||||
const fallback =
|
||||
component?.description || component?.desc || tm("status.unknown");
|
||||
if (getComponentGroupKey(component) === "page") {
|
||||
return String(
|
||||
pluginPageDescription(pluginData.value, component, fallback),
|
||||
).trim();
|
||||
}
|
||||
return String(fallback).trim();
|
||||
};
|
||||
|
||||
const openComponentPage = (component) => {
|
||||
const targetPluginName = component?.plugin_name || pluginData.value?.name;
|
||||
@@ -562,6 +587,20 @@ const fetchPluginDetail = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getDocumentUrl = (fieldName) => {
|
||||
const plugin = pluginData.value || {};
|
||||
const marketPlugin = props.marketPlugin || {};
|
||||
return String(plugin[fieldName] || marketPlugin[fieldName] || "").trim();
|
||||
};
|
||||
|
||||
const fetchRemoteMarkdown = async (url) => {
|
||||
const res = await axios.get(url, {
|
||||
responseType: "text",
|
||||
transformResponse: [(data) => data],
|
||||
});
|
||||
return typeof res.data === "string" ? res.data : String(res.data || "");
|
||||
};
|
||||
|
||||
const fetchReadme = async () => {
|
||||
const plugin = pluginData.value || {};
|
||||
if (!plugin?.name) return;
|
||||
@@ -572,7 +611,25 @@ const fetchReadme = async () => {
|
||||
renderedReadme.value = "";
|
||||
|
||||
if (isMarketDetail.value) {
|
||||
readmeLoading.value = false;
|
||||
const readmeUrl = getDocumentUrl("readme_url");
|
||||
if (!readmeUrl) {
|
||||
readmeEmpty.value = true;
|
||||
readmeLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fetchRemoteMarkdown(readmeUrl);
|
||||
if (!content.trim()) {
|
||||
readmeEmpty.value = true;
|
||||
return;
|
||||
}
|
||||
renderedReadme.value = renderMarkdown(content);
|
||||
} catch (err) {
|
||||
readmeError.value = err?.message || String(err);
|
||||
} finally {
|
||||
readmeLoading.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -617,14 +674,82 @@ const fetchReadme = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const showDocsSection = computed(() => !isMarketDetail.value);
|
||||
const fetchChangelog = async () => {
|
||||
const plugin = pluginData.value || {};
|
||||
if (!plugin?.name) return;
|
||||
|
||||
changelogLoading.value = true;
|
||||
changelogError.value = "";
|
||||
changelogEmpty.value = false;
|
||||
renderedChangelog.value = "";
|
||||
|
||||
if (isMarketDetail.value) {
|
||||
const changelogUrl = getDocumentUrl("changelog_url");
|
||||
if (!changelogUrl) {
|
||||
changelogEmpty.value = true;
|
||||
changelogLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fetchRemoteMarkdown(changelogUrl);
|
||||
if (!content.trim()) {
|
||||
changelogEmpty.value = true;
|
||||
return;
|
||||
}
|
||||
renderedChangelog.value = renderMarkdown(content);
|
||||
} catch (err) {
|
||||
changelogError.value = err?.message || String(err);
|
||||
} finally {
|
||||
changelogLoading.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get("/api/plugin/changelog", {
|
||||
params: { name: plugin.name },
|
||||
});
|
||||
|
||||
if (res.data.status !== "ok") {
|
||||
changelogError.value = res.data.message || tm("messages.operationFailed");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = res.data.data?.content || "";
|
||||
if (!content) {
|
||||
changelogEmpty.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
renderedChangelog.value = renderMarkdown(content);
|
||||
} catch (err) {
|
||||
changelogError.value = err?.message || String(err);
|
||||
} finally {
|
||||
changelogLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showDocsSection = computed(
|
||||
() => !isMarketDetail.value || !!getDocumentUrl("readme_url"),
|
||||
);
|
||||
|
||||
const showChangelogSection = computed(
|
||||
() => !isMarketDetail.value || !!getDocumentUrl("changelog_url"),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.plugin?.name, props.sourceTab],
|
||||
() => [
|
||||
props.plugin?.name,
|
||||
props.sourceTab,
|
||||
props.marketPlugin?.readme_url,
|
||||
props.marketPlugin?.changelog_url,
|
||||
],
|
||||
async () => {
|
||||
logoLoadFailed.value = false;
|
||||
await fetchPluginDetail();
|
||||
fetchReadme();
|
||||
fetchChangelog();
|
||||
scrollToHashTarget();
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -889,6 +1014,26 @@ onBeforeUnmount(() => {
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</section>
|
||||
|
||||
<section v-if="showChangelogSection" class="detail-section">
|
||||
<h3 class="detail-section__title">
|
||||
{{ tm("detail.changelogTitle") }}
|
||||
</h3>
|
||||
<v-card class="rounded-lg docs-card" variant="outlined">
|
||||
<v-card-text>
|
||||
<div v-if="changelogLoading" class="docs-state">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
</div>
|
||||
<v-alert v-else-if="changelogError" type="error" variant="tonal">
|
||||
{{ changelogError }}
|
||||
</v-alert>
|
||||
<div v-else-if="changelogEmpty" class="text-medium-emphasis">
|
||||
{{ tm("detail.changelogEmpty") }}
|
||||
</div>
|
||||
<div v-else class="docs-markdown" v-html="renderedChangelog"></div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ export const useExtensionPage = () => {
|
||||
extensionName: "",
|
||||
});
|
||||
|
||||
const updateConfirmDialog = reactive({
|
||||
show: false,
|
||||
extensionName: "",
|
||||
forceUpdate: false,
|
||||
});
|
||||
|
||||
// 更新全部插件确认对话框
|
||||
const updateAllConfirmDialog = reactive({
|
||||
show: false,
|
||||
@@ -596,6 +602,38 @@ export const useExtensionPage = () => {
|
||||
uninstall({ kind: "failed", id: dirName }, { skipConfirm: false });
|
||||
};
|
||||
|
||||
const normalizeInstallUrl = (value) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
const isGithubRepoUrl = (value) =>
|
||||
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?(?:\/tree\/[^/\s]+)?$/i.test(
|
||||
normalizeInstallUrl(value),
|
||||
);
|
||||
|
||||
const getInstalledExtensionByName = (extensionName) => {
|
||||
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
|
||||
return data.find((extension) => extension.name === extensionName) || null;
|
||||
};
|
||||
|
||||
const findMarketPluginForExtension = (extension) => {
|
||||
if (!extension) return null;
|
||||
const repo = normalizeInstallUrl(extension.repo).toLowerCase();
|
||||
return (
|
||||
pluginMarketData.value.find(
|
||||
(plugin) =>
|
||||
repo &&
|
||||
normalizeInstallUrl(plugin?.repo).toLowerCase() === repo,
|
||||
) ||
|
||||
pluginMarketData.value.find((plugin) => plugin.name === extension.name) ||
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const getUpdateDownloadUrl = (extension) =>
|
||||
String(findMarketPluginForExtension(extension)?.download_url || "").trim();
|
||||
|
||||
const checkUpdate = () => {
|
||||
const onlinePluginsMap = new Map();
|
||||
const onlinePluginsNameMap = new Map();
|
||||
@@ -657,10 +695,20 @@ export const useExtensionPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openUpdateConfirmDialog = (extensionName, forceUpdate = false) => {
|
||||
updateConfirmDialog.extensionName = extensionName;
|
||||
updateConfirmDialog.forceUpdate = forceUpdate;
|
||||
updateConfirmDialog.show = true;
|
||||
};
|
||||
|
||||
const closeUpdateConfirmDialog = () => {
|
||||
updateConfirmDialog.show = false;
|
||||
updateConfirmDialog.extensionName = "";
|
||||
updateConfirmDialog.forceUpdate = false;
|
||||
};
|
||||
|
||||
const updateExtension = async (extension_name, forceUpdate = false) => {
|
||||
// 查找插件信息
|
||||
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
|
||||
const ext = data.find((e) => e.name === extension_name);
|
||||
const ext = getInstalledExtensionByName(extension_name);
|
||||
|
||||
// 如果没有检测到更新且不是强制更新,则弹窗确认
|
||||
if (!ext?.has_update && !forceUpdate) {
|
||||
@@ -669,12 +717,28 @@ export const useExtensionPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
openUpdateConfirmDialog(extension_name, forceUpdate);
|
||||
};
|
||||
|
||||
const confirmUpdatePlugin = async () => {
|
||||
const extensionName = updateConfirmDialog.extensionName;
|
||||
const ext = getInstalledExtensionByName(extensionName);
|
||||
if (!extensionName || !ext) {
|
||||
closeUpdateConfirmDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = getUpdateDownloadUrl(ext);
|
||||
closeUpdateConfirmDialog();
|
||||
loadingDialog.title = tm("status.loading");
|
||||
loadingDialog.statusCode = 0;
|
||||
loadingDialog.result = "";
|
||||
loadingDialog.show = true;
|
||||
try {
|
||||
const res = await axios.post("/api/plugin/update", {
|
||||
name: extension_name,
|
||||
proxy: getSelectedGitHubProxy(),
|
||||
name: extensionName,
|
||||
download_url: downloadUrl,
|
||||
proxy: downloadUrl ? "" : getSelectedGitHubProxy(),
|
||||
});
|
||||
|
||||
if (res.data.status === "error") {
|
||||
@@ -692,7 +756,7 @@ export const useExtensionPage = () => {
|
||||
|
||||
// 更新完成后弹出更新日志
|
||||
viewChangelog({
|
||||
name: extension_name,
|
||||
name: extensionName,
|
||||
repo: ext?.repo || null,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -731,7 +795,7 @@ export const useExtensionPage = () => {
|
||||
const name = forceUpdateDialog.extensionName;
|
||||
forceUpdateDialog.show = false;
|
||||
forceUpdateDialog.extensionName = "";
|
||||
updateExtension(name, true);
|
||||
openUpdateConfirmDialog(name, true);
|
||||
};
|
||||
|
||||
const updateAllExtensions = async () => {
|
||||
@@ -747,9 +811,15 @@ export const useExtensionPage = () => {
|
||||
loadingDialog.show = true;
|
||||
|
||||
const targets = updatableExtensions.value.map((ext) => ext.name);
|
||||
const downloadUrls = Object.fromEntries(
|
||||
updatableExtensions.value
|
||||
.map((ext) => [ext.name, getUpdateDownloadUrl(ext)])
|
||||
.filter(([, downloadUrl]) => downloadUrl),
|
||||
);
|
||||
try {
|
||||
const res = await axios.post("/api/plugin/update-all", {
|
||||
names: targets,
|
||||
download_urls: downloadUrls,
|
||||
proxy: getSelectedGitHubProxy(),
|
||||
});
|
||||
|
||||
@@ -921,16 +991,6 @@ export const useExtensionPage = () => {
|
||||
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();
|
||||
@@ -1482,6 +1542,30 @@ export const useExtensionPage = () => {
|
||||
|
||||
const selectedInstallPlugin = computed(() => resolveSelectedInstallPlugin());
|
||||
|
||||
const selectedUpdateExtension = computed(() =>
|
||||
getInstalledExtensionByName(updateConfirmDialog.extensionName),
|
||||
);
|
||||
|
||||
const selectedUpdateMarketPlugin = computed(() =>
|
||||
findMarketPluginForExtension(selectedUpdateExtension.value),
|
||||
);
|
||||
|
||||
const selectedUpdateDownloadUrl = computed(() =>
|
||||
String(selectedUpdateMarketPlugin.value?.download_url || "").trim(),
|
||||
);
|
||||
|
||||
const selectedUpdateSourceUrl = computed(
|
||||
() =>
|
||||
selectedUpdateDownloadUrl.value ||
|
||||
String(selectedUpdateExtension.value?.repo || "").trim(),
|
||||
);
|
||||
|
||||
const updateUsesGithubSource = computed(
|
||||
() =>
|
||||
!selectedUpdateDownloadUrl.value &&
|
||||
isGithubRepoUrl(selectedUpdateSourceUrl.value),
|
||||
);
|
||||
|
||||
const checkInstallCompatibility = async () => {
|
||||
installCompat.checked = false;
|
||||
installCompat.compatible = true;
|
||||
@@ -1677,6 +1761,7 @@ export const useExtensionPage = () => {
|
||||
updatingAll,
|
||||
readmeDialog,
|
||||
forceUpdateDialog,
|
||||
updateConfirmDialog,
|
||||
updateAllConfirmDialog,
|
||||
changelogDialog,
|
||||
pluginSearch,
|
||||
@@ -1742,6 +1827,8 @@ export const useExtensionPage = () => {
|
||||
requestUninstallFailedPlugin,
|
||||
handleUninstallConfirm,
|
||||
updateExtension,
|
||||
closeUpdateConfirmDialog,
|
||||
confirmUpdatePlugin,
|
||||
showUpdateAllConfirm,
|
||||
confirmUpdateAll,
|
||||
cancelUpdateAll,
|
||||
@@ -1783,6 +1870,11 @@ export const useExtensionPage = () => {
|
||||
selectedInstallDownloadUrl,
|
||||
selectedInstallSourceUrl,
|
||||
installUsesGithubSource,
|
||||
selectedUpdateExtension,
|
||||
selectedUpdateMarketPlugin,
|
||||
selectedUpdateDownloadUrl,
|
||||
selectedUpdateSourceUrl,
|
||||
updateUsesGithubSource,
|
||||
checkInstallCompatibility,
|
||||
refreshPluginMarket,
|
||||
handleLocaleChange,
|
||||
|
||||
@@ -20,6 +20,7 @@ When the current locale has no translation, a field is missing, or the locale fi
|
||||
|
||||
- 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`.
|
||||
- Page text falls back to the Page directory name, default Page title, or fallback text provided by page code.
|
||||
|
||||
## Metadata
|
||||
|
||||
@@ -77,6 +78,52 @@ Corresponding `.astrbot-plugin/i18n/zh-CN.json`:
|
||||
|
||||
`options` are stored configuration values and should usually not be translated. Use `labels` for select display text.
|
||||
|
||||
## Plugin Pages
|
||||
|
||||
`pages` overrides plugin Dashboard Page titles, descriptions, and custom text inside plugin pages. The structure is nested by Page directory name.
|
||||
|
||||
Example plugin page directory:
|
||||
|
||||
```text
|
||||
pages/
|
||||
settings/
|
||||
index.html
|
||||
```
|
||||
|
||||
Corresponding `.astrbot-plugin/i18n/en-US.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": {
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"description": "Manage advanced settings for this plugin.",
|
||||
"save": "Save",
|
||||
"reset": "Reset"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`title` is used by the WebUI shell title and the Page component name on the plugin detail page. `description` is used by the Page component description on the plugin detail page. Other fields are read by the page through the bridge:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
|
||||
function render() {
|
||||
document.getElementById("save").textContent = bridge.t(
|
||||
"pages.settings.save",
|
||||
"Save",
|
||||
);
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
bridge.onContext(render);
|
||||
```
|
||||
|
||||
Use `onContext()` to react to WebUI language changes; with this listener, the Page usually does not need a refresh.
|
||||
|
||||
## Nested Configuration
|
||||
|
||||
For `object` items in `_conf_schema.json`, translations use the same nested field structure.
|
||||
|
||||
@@ -96,6 +96,10 @@ Inside a plugin Page, use `window.AstrBotPluginPage` directly:
|
||||
|
||||
- `ready()`: Wait until the bridge is ready and return the context
|
||||
- `getContext()`: Read the current context
|
||||
- `getLocale()`: Read the current WebUI locale
|
||||
- `getI18n()`: Read the current plugin i18n resources
|
||||
- `t(key, fallback)`: Read text from plugin i18n resources by key, returning fallback when missing
|
||||
- `onContext(handler)`: Listen for context changes, such as rerendering after the WebUI locale changes
|
||||
- `apiGet(endpoint, params)`: Send a GET request
|
||||
- `apiPost(endpoint, body)`: Send a POST request
|
||||
- `upload(endpoint, file)`: Upload one file as `multipart/form-data`
|
||||
@@ -108,12 +112,62 @@ The current `ready()` context looks like this:
|
||||
```json
|
||||
{
|
||||
"pluginName": "astrbot_plugin_page_demo",
|
||||
"displayName": "Plugin Page Demo"
|
||||
"displayName": "Plugin Page Demo",
|
||||
"pageName": "bridge-demo",
|
||||
"pageTitle": "Bridge Demo",
|
||||
"locale": "en-US",
|
||||
"i18n": {}
|
||||
}
|
||||
```
|
||||
|
||||
`endpoint` must be a plugin-local path. It must not be empty, contain `\`, contain a URL scheme, contain query strings or fragments, or contain `.` / `..` path segments.
|
||||
|
||||
## Page Internationalization
|
||||
|
||||
Plugin Pages reuse plugin i18n resource files. Add `pages.<page_name>` to `.astrbot-plugin/i18n/<locale>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": {
|
||||
"bridge-demo": {
|
||||
"title": "Bridge Demo",
|
||||
"description": "Shows how a plugin page reads the WebUI locale and translations.",
|
||||
"heading": "Plugin Page",
|
||||
"refresh": "Render again"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`title` is used by the WebUI shell title and the Page component name on the plugin detail page. `description` is used by the Page component description on the plugin detail page.
|
||||
|
||||
Inside the Page, render text with `t()` and react to language changes with `onContext()`:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
|
||||
function render() {
|
||||
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
|
||||
document.getElementById("heading").textContent = bridge.t(
|
||||
"pages.bridge-demo.heading",
|
||||
"Plugin Page",
|
||||
);
|
||||
document.getElementById("locale").textContent = bridge.getLocale();
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
bridge.onContext(render);
|
||||
```
|
||||
|
||||
After the WebUI locale changes, the Dashboard sends the new `locale` and plugin i18n resources to the iframe through the bridge. If the Page listens with `onContext()`, it usually does not need a refresh.
|
||||
|
||||
If an inline script needs to access `window.AstrBotPluginPage` synchronously, move the code to an external module file or explicitly include the bridge SDK before your script:
|
||||
|
||||
```html
|
||||
<script src="/api/plugin/page/bridge-sdk.js"></script>
|
||||
```
|
||||
|
||||
## Asset Path Rules
|
||||
|
||||
AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Write normal relative paths and do not hardcode `/api/plugin/page/content/...` yourself.
|
||||
@@ -151,3 +205,184 @@ AstrBot also adds security headers to asset responses, including:
|
||||
- Reload the plugin after adding or removing a Page directory
|
||||
- For most edits under `pages/<page_name>/`, refreshing the Page is enough
|
||||
- If a Page does not appear, check that `pages/<page_name>/index.html` exists and the plugin is enabled
|
||||
|
||||
## Appendix: Bridge API Details
|
||||
|
||||
Start page scripts by keeping a bridge reference:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
```
|
||||
|
||||
### `ready()`
|
||||
|
||||
Waits for the parent page to send the initial context and returns `Promise<context>`. Page initialization should wait for this before reading context-dependent values.
|
||||
|
||||
```js
|
||||
const context = await bridge.ready();
|
||||
console.log(context.pluginName, context.pageName, context.locale);
|
||||
```
|
||||
|
||||
The context usually contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"pluginName": "astrbot_plugin_page_demo",
|
||||
"displayName": "Plugin Page Demo",
|
||||
"pageName": "bridge-demo",
|
||||
"pageTitle": "Bridge Demo",
|
||||
"locale": "en-US",
|
||||
"i18n": {}
|
||||
}
|
||||
```
|
||||
|
||||
### `getContext()`
|
||||
|
||||
Synchronously reads the latest context. Use it after `ready()` or inside an `onContext()` callback.
|
||||
|
||||
```js
|
||||
function renderHeader() {
|
||||
const context = bridge.getContext();
|
||||
document.getElementById("title").textContent = context.pageTitle;
|
||||
}
|
||||
```
|
||||
|
||||
### `getLocale()`
|
||||
|
||||
Synchronously reads the current WebUI locale. It returns `zh-CN` when no context exists yet.
|
||||
|
||||
```js
|
||||
document.documentElement.lang = bridge.getLocale();
|
||||
```
|
||||
|
||||
### `getI18n()`
|
||||
|
||||
Synchronously reads the full plugin i18n resource object. Prefer `t()` for normal rendering; use this for custom traversal or debugging.
|
||||
|
||||
```js
|
||||
console.log(Object.keys(bridge.getI18n()));
|
||||
```
|
||||
|
||||
### `t(key, fallback)`
|
||||
|
||||
Reads text from plugin i18n by dot-separated key. If the current locale is missing, the bridge tries fallbacks; if still missing, it returns `fallback`.
|
||||
|
||||
```js
|
||||
saveButton.textContent = bridge.t("pages.settings.save", "Save");
|
||||
```
|
||||
|
||||
### `onContext(handler)`
|
||||
|
||||
Listens for context changes and returns an unsubscribe function. The callback runs when the WebUI locale changes, so pages that need live language switching should rerender here.
|
||||
|
||||
```js
|
||||
function render() {
|
||||
document.title = bridge.t("pages.settings.title", "Settings");
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
|
||||
const off = bridge.onContext(render);
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
off();
|
||||
});
|
||||
```
|
||||
|
||||
### `apiGet(endpoint, params)`
|
||||
|
||||
Sends a GET request to the plugin backend and returns `Promise<data>`. `endpoint` is a plugin-local relative path; the Dashboard forwards it to `/api/plug/<plugin_name>/<endpoint>`.
|
||||
|
||||
```js
|
||||
const stats = await bridge.apiGet("stats", { limit: 20 });
|
||||
```
|
||||
|
||||
Backend registration example:
|
||||
|
||||
```python
|
||||
context.register_web_api(
|
||||
f"/{PLUGIN_NAME}/stats",
|
||||
self.get_stats,
|
||||
["GET"],
|
||||
"Get stats",
|
||||
)
|
||||
```
|
||||
|
||||
### `apiPost(endpoint, body)`
|
||||
|
||||
Sends a POST request to the plugin backend. `body` is sent as JSON and the method returns `Promise<data>`.
|
||||
|
||||
```js
|
||||
await bridge.apiPost("settings/save", {
|
||||
enabled: true,
|
||||
threshold: 0.8,
|
||||
});
|
||||
```
|
||||
|
||||
### `upload(endpoint, file)`
|
||||
|
||||
Uploads one file as `multipart/form-data` with the field name `file`, returning `Promise<data>`.
|
||||
|
||||
```js
|
||||
const input = document.querySelector("input[type=file]");
|
||||
const file = input.files[0];
|
||||
const result = await bridge.upload("files/import", file);
|
||||
```
|
||||
|
||||
### `download(endpoint, params, filename)`
|
||||
|
||||
Requests a plugin backend file endpoint and triggers a browser download. `params` are sent as query parameters. `filename` is optional; when omitted, the bridge tries to use the response filename header.
|
||||
|
||||
```js
|
||||
await bridge.download("files/export", { format: "json" }, "export.json");
|
||||
```
|
||||
|
||||
### `subscribeSSE(endpoint, handlers, params)`
|
||||
|
||||
Subscribes to plugin backend SSE and returns `Promise<subscriptionId>`. `handlers` may include `onOpen`, `onMessage`, and `onError`.
|
||||
|
||||
```js
|
||||
const subscriptionId = await bridge.subscribeSSE(
|
||||
"events",
|
||||
{
|
||||
onOpen() {
|
||||
console.log("SSE opened");
|
||||
},
|
||||
onMessage(event) {
|
||||
console.log(event.raw, event.parsed, event.lastEventId);
|
||||
},
|
||||
onError() {
|
||||
console.warn("SSE error");
|
||||
},
|
||||
},
|
||||
{ topic: "logs" },
|
||||
);
|
||||
```
|
||||
|
||||
`event.parsed` is automatically parsed when the message is a JSON string; otherwise it equals the raw string.
|
||||
|
||||
### `unsubscribeSSE(subscriptionId)`
|
||||
|
||||
Cancels an SSE subscription.
|
||||
|
||||
```js
|
||||
await bridge.unsubscribeSSE(subscriptionId);
|
||||
```
|
||||
|
||||
Clean up subscriptions when the page unloads:
|
||||
|
||||
```js
|
||||
window.addEventListener("beforeunload", () => {
|
||||
bridge.unsubscribeSSE(subscriptionId);
|
||||
});
|
||||
```
|
||||
|
||||
### endpoint Rules
|
||||
|
||||
The `endpoint` used by `apiGet`, `apiPost`, `upload`, `download`, and `subscribeSSE` must be a plugin-local relative path:
|
||||
|
||||
- Allowed: `"stats"`, `"settings/save"`, `"files/export"`
|
||||
- Not allowed: empty string, `"/stats"`, `"../stats"`, `"https://example.com"`, `"stats?x=1"`, `"stats#top"`
|
||||
|
||||
Pass query parameters through `params`; do not append them to `endpoint`.
|
||||
|
||||
@@ -20,6 +20,7 @@ your_plugin/
|
||||
|
||||
- 插件名称、卡片短描述和描述回退到 `metadata.yaml` 中的 `display_name`、`short_desc`、`desc`。
|
||||
- 配置项文案回退到 `_conf_schema.json` 中的 `description`、`hint`、`labels`。
|
||||
- Page 文案回退到 Page 目录名、Page 默认标题或页面代码中提供的 fallback。
|
||||
|
||||
## 元数据
|
||||
|
||||
@@ -77,6 +78,52 @@ your_plugin/
|
||||
|
||||
`options` 是配置保存值,不建议翻译。下拉框的展示文本请使用 `labels`。
|
||||
|
||||
## 插件 Pages
|
||||
|
||||
`pages` 用于覆盖插件 Dashboard Page 的标题、描述和页面内自定义文案。结构按 Page 目录名嵌套。
|
||||
|
||||
例如插件页面目录:
|
||||
|
||||
```text
|
||||
pages/
|
||||
settings/
|
||||
index.html
|
||||
```
|
||||
|
||||
对应 `.astrbot-plugin/i18n/zh-CN.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": {
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"description": "管理这个插件的高级设置。",
|
||||
"save": "保存",
|
||||
"reset": "重置"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`title` 会用于 WebUI 外壳标题和插件详情页中的 Page 组件名称,`description` 会用于插件详情页中的 Page 组件描述。其他字段由页面通过 bridge 自行读取:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
|
||||
function render() {
|
||||
document.getElementById("save").textContent = bridge.t(
|
||||
"pages.settings.save",
|
||||
"Save",
|
||||
);
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
bridge.onContext(render);
|
||||
```
|
||||
|
||||
`onContext()` 用于响应 WebUI 语言切换;监听后通常不需要刷新 Page。
|
||||
|
||||
## 嵌套配置
|
||||
|
||||
如果 `_conf_schema.json` 中有 `object` 类型配置,翻译也按同样的字段结构继续嵌套。
|
||||
|
||||
@@ -96,6 +96,10 @@ class MyPlugin(Star):
|
||||
|
||||
- `ready()`: 等待 bridge 就绪并返回上下文
|
||||
- `getContext()`: 读取当前上下文
|
||||
- `getLocale()`: 读取当前 WebUI 语言
|
||||
- `getI18n()`: 读取当前插件的 i18n 资源
|
||||
- `t(key, fallback)`: 从插件 i18n 资源中按 key 获取文案,缺失时返回 fallback
|
||||
- `onContext(handler)`: 监听上下文变化,例如 WebUI 切换语言后重新渲染页面
|
||||
- `apiGet(endpoint, params)`: 发送 GET 请求
|
||||
- `apiPost(endpoint, body)`: 发送 POST 请求
|
||||
- `upload(endpoint, file)`: 以 `multipart/form-data` 上传单个文件
|
||||
@@ -108,12 +112,62 @@ class MyPlugin(Star):
|
||||
```json
|
||||
{
|
||||
"pluginName": "astrbot_plugin_page_demo",
|
||||
"displayName": "Plugin Page Demo"
|
||||
"displayName": "Plugin Page Demo",
|
||||
"pageName": "bridge-demo",
|
||||
"pageTitle": "Bridge Demo",
|
||||
"locale": "zh-CN",
|
||||
"i18n": {}
|
||||
}
|
||||
```
|
||||
|
||||
`endpoint` 必须是插件内相对路径,不能为空,不能包含 `\`、URL scheme、query、hash,也不能包含 `.` 或 `..` 路径片段。
|
||||
|
||||
## Page 国际化
|
||||
|
||||
插件 Page 复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/<locale>.json` 增加 `pages.<page_name>` 即可:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": {
|
||||
"bridge-demo": {
|
||||
"title": "Bridge 演示页",
|
||||
"description": "演示插件页面如何读取 WebUI 语言和翻译资源。",
|
||||
"heading": "插件页面",
|
||||
"refresh": "重新渲染"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`title` 会用于 WebUI 外壳标题和插件详情页的 Page 组件名称,`description` 会用于插件详情页的 Page 组件描述。
|
||||
|
||||
在 Page 内部使用 `t()` 渲染文案,并用 `onContext()` 响应语言切换:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
|
||||
function render() {
|
||||
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
|
||||
document.getElementById("heading").textContent = bridge.t(
|
||||
"pages.bridge-demo.heading",
|
||||
"Plugin Page",
|
||||
);
|
||||
document.getElementById("locale").textContent = bridge.getLocale();
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
bridge.onContext(render);
|
||||
```
|
||||
|
||||
切换 WebUI 语言后,Dashboard 会把新的 `locale` 和插件 i18n 资源通过 bridge 发送给 iframe;只要 Page 监听了 `onContext()`,通常不需要刷新页面。
|
||||
|
||||
如果你的内联脚本需要同步访问 `window.AstrBotPluginPage`,请把脚本放到外部 module 文件中,或在自己的脚本前显式引入:
|
||||
|
||||
```html
|
||||
<script src="/api/plugin/page/bridge-sdk.js"></script>
|
||||
```
|
||||
|
||||
## 静态资源路径规则
|
||||
|
||||
AstrBot 会重写相对资源路径,并自动补上短期 `asset_token`。你只需要正常写相对路径,不要自己拼接 `/api/plugin/page/content/...`。
|
||||
@@ -151,3 +205,184 @@ AstrBot 还会给资源响应添加安全头,包括:
|
||||
- 新增或删除 Page 目录后重载插件
|
||||
- 修改 `pages/<page_name>/` 下的大多数静态资源后,刷新 Page 即可
|
||||
- 如果 Page 没出现,检查 `pages/<page_name>/index.html` 是否存在,以及插件是否启用
|
||||
|
||||
## 附录:Bridge API 详解
|
||||
|
||||
建议在页面脚本开始处保存 bridge 引用:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
```
|
||||
|
||||
### `ready()`
|
||||
|
||||
等待父页面发送初始上下文,返回 `Promise<context>`。页面初始化时应先等待它,避免过早读取空上下文。
|
||||
|
||||
```js
|
||||
const context = await bridge.ready();
|
||||
console.log(context.pluginName, context.pageName, context.locale);
|
||||
```
|
||||
|
||||
上下文通常包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"pluginName": "astrbot_plugin_page_demo",
|
||||
"displayName": "Plugin Page Demo",
|
||||
"pageName": "bridge-demo",
|
||||
"pageTitle": "Bridge Demo",
|
||||
"locale": "zh-CN",
|
||||
"i18n": {}
|
||||
}
|
||||
```
|
||||
|
||||
### `getContext()`
|
||||
|
||||
同步读取最近一次上下文。适合在 `ready()` 之后或 `onContext()` 回调中使用。
|
||||
|
||||
```js
|
||||
function renderHeader() {
|
||||
const context = bridge.getContext();
|
||||
document.getElementById("title").textContent = context.pageTitle;
|
||||
}
|
||||
```
|
||||
|
||||
### `getLocale()`
|
||||
|
||||
同步读取当前 WebUI 语言。没有上下文时默认返回 `zh-CN`。
|
||||
|
||||
```js
|
||||
document.documentElement.lang = bridge.getLocale();
|
||||
```
|
||||
|
||||
### `getI18n()`
|
||||
|
||||
同步读取当前插件的完整 i18n 资源对象。一般优先用 `t()`,只有需要自定义遍历或调试时才直接读取。
|
||||
|
||||
```js
|
||||
console.log(Object.keys(bridge.getI18n()));
|
||||
```
|
||||
|
||||
### `t(key, fallback)`
|
||||
|
||||
按点分隔 key 从插件 i18n 中取文案。当前语言缺失时会尝试回退,仍缺失则返回 `fallback`。
|
||||
|
||||
```js
|
||||
saveButton.textContent = bridge.t("pages.settings.save", "Save");
|
||||
```
|
||||
|
||||
### `onContext(handler)`
|
||||
|
||||
监听上下文变化,返回取消监听函数。WebUI 切换语言时会触发该回调,所以需要响应语言切换的页面应在这里重新渲染。
|
||||
|
||||
```js
|
||||
function render() {
|
||||
document.title = bridge.t("pages.settings.title", "Settings");
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
|
||||
const off = bridge.onContext(render);
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
off();
|
||||
});
|
||||
```
|
||||
|
||||
### `apiGet(endpoint, params)`
|
||||
|
||||
向插件后端发送 GET 请求,返回 `Promise<data>`。`endpoint` 是插件内相对路径,Dashboard 会转发到 `/api/plug/<plugin_name>/<endpoint>`。
|
||||
|
||||
```js
|
||||
const stats = await bridge.apiGet("stats", { limit: 20 });
|
||||
```
|
||||
|
||||
后端注册示例:
|
||||
|
||||
```python
|
||||
context.register_web_api(
|
||||
f"/{PLUGIN_NAME}/stats",
|
||||
self.get_stats,
|
||||
["GET"],
|
||||
"Get stats",
|
||||
)
|
||||
```
|
||||
|
||||
### `apiPost(endpoint, body)`
|
||||
|
||||
向插件后端发送 POST 请求,`body` 会作为 JSON 请求体发送,返回 `Promise<data>`。
|
||||
|
||||
```js
|
||||
await bridge.apiPost("settings/save", {
|
||||
enabled: true,
|
||||
threshold: 0.8,
|
||||
});
|
||||
```
|
||||
|
||||
### `upload(endpoint, file)`
|
||||
|
||||
以 `multipart/form-data` 上传单个文件,字段名为 `file`,返回 `Promise<data>`。
|
||||
|
||||
```js
|
||||
const input = document.querySelector("input[type=file]");
|
||||
const file = input.files[0];
|
||||
const result = await bridge.upload("files/import", file);
|
||||
```
|
||||
|
||||
### `download(endpoint, params, filename)`
|
||||
|
||||
请求插件后端文件接口并触发浏览器下载。`params` 会作为 query string,`filename` 可选;不传时会尝试使用响应头里的文件名。
|
||||
|
||||
```js
|
||||
await bridge.download("files/export", { format: "json" }, "export.json");
|
||||
```
|
||||
|
||||
### `subscribeSSE(endpoint, handlers, params)`
|
||||
|
||||
订阅插件后端 SSE,返回 `Promise<subscriptionId>`。`handlers` 可包含 `onOpen`、`onMessage`、`onError`。
|
||||
|
||||
```js
|
||||
const subscriptionId = await bridge.subscribeSSE(
|
||||
"events",
|
||||
{
|
||||
onOpen() {
|
||||
console.log("SSE opened");
|
||||
},
|
||||
onMessage(event) {
|
||||
console.log(event.raw, event.parsed, event.lastEventId);
|
||||
},
|
||||
onError() {
|
||||
console.warn("SSE error");
|
||||
},
|
||||
},
|
||||
{ topic: "logs" },
|
||||
);
|
||||
```
|
||||
|
||||
`event.parsed` 会在消息内容是 JSON 字符串时自动解析,否则等于原始字符串。
|
||||
|
||||
### `unsubscribeSSE(subscriptionId)`
|
||||
|
||||
取消 SSE 订阅。
|
||||
|
||||
```js
|
||||
await bridge.unsubscribeSSE(subscriptionId);
|
||||
```
|
||||
|
||||
页面卸载时建议清理订阅:
|
||||
|
||||
```js
|
||||
window.addEventListener("beforeunload", () => {
|
||||
bridge.unsubscribeSSE(subscriptionId);
|
||||
});
|
||||
```
|
||||
|
||||
### endpoint 规则
|
||||
|
||||
`apiGet`、`apiPost`、`upload`、`download`、`subscribeSSE` 的 `endpoint` 必须是插件内相对路径:
|
||||
|
||||
- 允许:`"stats"`、`"settings/save"`、`"files/export"`
|
||||
- 不允许:空字符串、`"/stats"`、`"../stats"`、`"https://example.com"`、`"stats?x=1"`、`"stats#top"`
|
||||
|
||||
query 参数请通过 `params` 传递,不要拼进 `endpoint`。
|
||||
|
||||
@@ -47,10 +47,12 @@ def registered_plugin_page(core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch)
|
||||
Path(core_lifecycle_td.plugin_manager.plugin_store_path) / PLUGIN_PAGE_DEMO_NAME
|
||||
)
|
||||
page_root = plugin_root / "pages" / PLUGIN_PAGE_DEMO_PAGE_NAME
|
||||
i18n_root = plugin_root / ".astrbot-plugin" / "i18n"
|
||||
shared_root = page_root / "shared"
|
||||
images_root = page_root / "images"
|
||||
shared_root.mkdir(parents=True, exist_ok=True)
|
||||
images_root.mkdir(parents=True, exist_ok=True)
|
||||
i18n_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(page_root / "index.html").write_text(
|
||||
"""
|
||||
@@ -94,6 +96,21 @@ window.renderTabs = renderTabs;
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"></svg>\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(i18n_root / "zh-CN.json").write_text(
|
||||
"""
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "插件页面演示"
|
||||
},
|
||||
"pages": {
|
||||
"bridge-demo": {
|
||||
"title": "Bridge 演示页"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
plugin = StarMetadata(
|
||||
name=PLUGIN_PAGE_DEMO_NAME,
|
||||
@@ -326,6 +343,7 @@ async def test_plugin_detail_includes_scanned_page_component(
|
||||
"name": PLUGIN_PAGE_DEMO_PAGE_NAME,
|
||||
"title": PLUGIN_PAGE_DEMO_PAGE_NAME,
|
||||
"page_name": PLUGIN_PAGE_DEMO_PAGE_NAME,
|
||||
"i18n_key": f"pages.{PLUGIN_PAGE_DEMO_PAGE_NAME}",
|
||||
"description": "Plugin Page entry",
|
||||
"plugin_name": PLUGIN_PAGE_DEMO_NAME,
|
||||
}
|
||||
@@ -351,6 +369,7 @@ async def test_plugin_page_entry_returns_signed_content_path(
|
||||
assert data["status"] == "ok"
|
||||
assert data["data"]["name"] == PLUGIN_PAGE_DEMO_PAGE_NAME
|
||||
assert data["data"]["title"] == PLUGIN_PAGE_DEMO_PAGE_NAME
|
||||
assert data["data"]["i18n_key"] == f"pages.{PLUGIN_PAGE_DEMO_PAGE_NAME}"
|
||||
assert data["data"]["content_path"].startswith(
|
||||
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
|
||||
)
|
||||
@@ -467,6 +486,11 @@ async def test_plugin_page_content_issues_scoped_asset_token(
|
||||
assert app_js_response.status_code == 200
|
||||
bridge_response = await anonymous_client.get(bridge_sdk_url.group(1))
|
||||
assert bridge_response.status_code == 200
|
||||
bridge_js = (await bridge_response.get_data()).decode("utf-8")
|
||||
assert "window.AstrBotPluginPage?.__setInitialContext" in bridge_js
|
||||
assert '"locale": "zh-CN"' in bridge_js
|
||||
assert '"displayName": "插件页面演示"' in bridge_js
|
||||
assert '"pageTitle": "Bridge 演示页"' in bridge_js
|
||||
css_response = await anonymous_client.get(css_url.group(1))
|
||||
assert css_response.status_code == 200
|
||||
|
||||
|
||||
Reference in New Issue
Block a user