* feat(plugin): pass theme through plugin page asset URLs and initial context
Add theme query parameter propagation through the plugin page asset pipeline
so that the bridge SDK initial context includes isDark.
* feat(plugin): inject data-theme and color-scheme into plugin page HTML
Set data-theme on <html> and add color-scheme meta tag server-side
to prevent flash when entering plugin pages in dark mode.
* feat(webui): add isDark getter to customizer store
* feat(webui): sync theme to plugin iframe via URL param and postMessage
Append theme query parameter to iframe src URL and include isDark
in the postMessage context. Watch uiTheme changes to re-send context.
* feat(bridge): auto-apply data-theme from context in plugin bridge SDK
Set data-theme attribute on document.documentElement when context
includes isDark, enabling live theme switching via postMessage.
* docs: add light/dark theme adaptation guide for plugin pages
Add theme adaptation section to existing plugin-pages docs in both
Chinese and English, covering CSS variables and onContext() usage.
* test: add theme sync tests for plugin page bridge and content
Verify isDark in bridge SDK initial context with various theme params,
and verify data-theme and color-scheme injection in rewritten HTML.
* fix(plugin): use case-insensitive regex for head tag in HTML rewrite
Replace string match for <head> with case-insensitive regex to handle
uppercase tags and tags with attributes, preventing duplicate injection
of color-scheme meta tag.
* refactor(webui): generalize isDark getter to support any dark theme
Replace hardcoded PurpleThemeDark check with suffix-based detection
so all dark theme variants are recognized automatically.
* refactor(plugin): extract theme helpers for HTML rewriting
Extract _get_request_theme and _apply_theme_to_html to eliminate
duplicate theme-parsing logic and isolate HTML mutation. Use case-
insensitive regex for head tag detection to prevent duplicate
injection when tags use mixed casing.
* style: apply ruff formatting to plugin page tests
Wrap long function call lines for consistency with project style.
* fix(plugin): handle existing data-theme and case-sensitive fallback in HTML rewrite
Strip any existing data-theme attribute before adding the new one to
prevent duplicate attributes. Use case-insensitive regex for the
<head> fallback insertion to avoid corrupting <html> tag attributes.
* fix(webui): add null guard to isDark getter
Guard against undefined uiTheme to prevent TypeError when the
theme config has not been initialized.
* fix(webui): centralize theme mapping and preserve hash in plugin page URL
Extract themeParam computed to avoid drift between URL and context.
Include hash fragment in iframe URL to support SPA hash routing.
* fix(webui): address CR feedback - deduplicate color-scheme meta, harden isDark getter, consolidate theme source
- _apply_theme_to_html: strip existing color-scheme meta before injecting to
avoid duplicates; merge data-theme strip+add into single-pass regex callback
- customizer.ts: replace brittle endsWith('Dark') with explicit theme name check
- _rewrite_plugin_page_html: use _get_request_theme() directly instead of
reading theme from extra_query_params
* fix(webui): 简化 isDark 推导、优化查询参数构建、使用暗色主题集合
- isDark 推导简化为 theme == "dark"(None == "dark" 为 False,去掉多余三元表达式)
- _prepare_plugin_page_query_params 改为线性构建,先计算再构建字典
- isDark getter 改用显式 DARK_THEMES Set,替代硬编码单值比较
---------
Co-authored-by: lxfight <lxfight@192.168.5.50>
12 KiB
插件 Pages
AstrBot 支持插件通过 pages/ 目录暴露 Dashboard 页面。pages/ 下的每个一级子目录都是一个独立 Page:
astrbot_plugin_page_demo/
├─ main.py
└─ pages/
├─ bridge-demo/
│ ├─ index.html
│ ├─ app.js
│ ├─ style.css
│ └─ assets/
│ └─ logo.svg
└─ settings/
└─ index.html
AstrBot 会扫描 pages/<page_name>/index.html;没有 index.html 的目录会被忽略。
如果只是让用户填写几个配置项,优先使用 _conf_schema.json。插件 Pages 更适合复杂表单、Dashboard、日志、文件上传下载、SSE 和自定义交互流程。
一旦注册了 Pages,用户可以在:AstrBot WebUI 插件页中的插件卡片中,点击插件卡片进入插件详细页面,在插件详细页面中可以看到并进入注册的 Pages。
最小前端示例
pages/bridge-demo/index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Plugin Page Demo</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<button id="ping">Ping</button>
<pre id="output"></pre>
<script type="module" src="./app.js"></script>
</body>
</html>
pages/bridge-demo/app.js
const bridge = window.AstrBotPluginPage;
const output = document.getElementById("output");
const context = await bridge.ready();
output.textContent = JSON.stringify(context, null, 2);
document.getElementById("ping").addEventListener("click", async () => {
const result = await bridge.apiGet("ping");
output.textContent = JSON.stringify(result, null, 2);
});
这里不需要手动引入 bridge SDK。AstrBot 会在返回的 HTML 里自动插入 /api/plugin/page/bridge-sdk.js。
注册后端 API
前端调用 bridge.apiGet("ping") 时,Dashboard 会转发到:
/api/plug/<plugin_name>/ping
因此注册 Web API 时,路由必须带上插件名作为前缀:
from quart import jsonify
from astrbot.api.star import Context, Star
PLUGIN_NAME = "astrbot_plugin_page_demo"
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
context.register_web_api(
f"/{PLUGIN_NAME}/ping",
self.page_ping,
["GET"],
"Page ping",
)
async def page_ping(self):
return jsonify({"message": "pong"})
Bridge API
插件 Page 中可直接使用 window.AstrBotPluginPage:
ready(): 等待 bridge 就绪并返回上下文getContext(): 读取当前上下文getLocale(): 读取当前 WebUI 语言getI18n(): 读取当前插件的 i18n 资源t(key, fallback): 从插件 i18n 资源中按 key 获取文案,缺失时返回 fallbackonContext(handler): 监听上下文变化,例如 WebUI 切换语言后重新渲染页面apiGet(endpoint, params): 发送 GET 请求apiPost(endpoint, body): 发送 POST 请求upload(endpoint, file): 以multipart/form-data上传单个文件download(endpoint, params, filename): 下载后端响应subscribeSSE(endpoint, handlers, params): 订阅 SSEunsubscribeSSE(subscriptionId): 取消 SSE 订阅
当前 ready() 上下文类似:
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "zh-CN",
"i18n": {},
"isDark": false
}
endpoint 必须是插件内相对路径,不能为空,不能包含 \、URL scheme、query、hash,也不能包含 . 或 .. 路径片段。
Page 国际化
插件 Page 复用插件 i18n 资源文件。给 .astrbot-plugin/i18n/<locale>.json 增加 pages.<page_name> 即可:
{
"pages": {
"bridge-demo": {
"title": "Bridge 演示页",
"description": "演示插件页面如何读取 WebUI 语言和翻译资源。",
"heading": "插件页面",
"refresh": "重新渲染"
}
}
}
title 会用于 WebUI 外壳标题和插件详情页的 Page 组件名称,description 会用于插件详情页的 Page 组件描述。
在 Page 内部使用 t() 渲染文案,并用 onContext() 响应语言切换:
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 文件中,或在自己的脚本前显式引入:
<script src="/api/plugin/page/bridge-sdk.js"></script>
亮暗主题适配
AstrBot 切换亮色/暗色模式时,会自动将主题状态同步给插件 Page。bridge SDK 会在 <html> 元素上维护 data-theme 属性:
- 亮色模式:
<html data-theme="light"> - 暗色模式:
<html data-theme="dark">
CSS 适配
推荐使用 CSS 变量:
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
[data-theme="dark"] {
--bg: #1a1a1a;
--text: #e0e0e0;
}
body {
background: var(--bg);
color: var(--text);
}
AstrBot 在服务端返回 HTML 时,会自动在 <html> 标签上注入 data-theme 属性,不会出现初始闪烁。
JavaScript 响应主题变化
上下文中的 isDark 字段表示当前是否为暗色模式。通过 onContext() 可以监听主题切换:
const bridge = window.AstrBotPluginPage;
function render() {
if (bridge.getContext()?.isDark) {
// 暗色模式下的 JS 逻辑(如图表滤镜等)
}
}
await bridge.ready();
render();
bridge.onContext(render);
切换亮暗模式时,Dashboard 会通过 bridge 将更新后的上下文发送给 iframe;只要 Page 监听了 onContext(),就会收到通知。
静态资源路径规则
AstrBot 会重写相对资源路径,并自动补上短期 asset_token。你只需要正常写相对路径,不要自己拼接 /api/plugin/page/content/...。
AstrBot 会重写:
- HTML
src和href - CSS
url(...) - JavaScript
import - JavaScript
export ... from - JavaScript 动态
import()
建议把静态资源写成 ./style.css、./assets/logo.svg 这类相对路径。不要手动追加 asset_token,也不要依赖 .. 逃逸 Page 根目录。
如果你构建 SPA,建议使用 hash routing。静态资源服务按真实文件路径解析;history routing 刷新页面时需要对应路径上真的存在文件。
安全约束
插件 Pages 运行在受限 iframe 中:
allow-scripts allow-forms allow-downloads
Page 不能直接访问 Dashboard cookies、LocalStorage 或同源 DOM,也不能绕过 bridge 复用 Dashboard auth。
AstrBot 还会给资源响应添加安全头,包括:
X-Frame-Options: SAMEORIGINContent-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'Cache-Control: no-store
调试建议
- 新增或删除 Page 目录后重载插件
- 修改
pages/<page_name>/下的大多数静态资源后,刷新 Page 即可 - 如果 Page 没出现,检查
pages/<page_name>/index.html是否存在,以及插件是否启用
附录:Bridge API 详解
建议在页面脚本开始处保存 bridge 引用:
const bridge = window.AstrBotPluginPage;
ready()
等待父页面发送初始上下文,返回 Promise<context>。页面初始化时应先等待它,避免过早读取空上下文。
const context = await bridge.ready();
console.log(context.pluginName, context.pageName, context.locale);
上下文通常包含:
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "zh-CN",
"i18n": {},
"isDark": false
}
getContext()
同步读取最近一次上下文。适合在 ready() 之后或 onContext() 回调中使用。
function renderHeader() {
const context = bridge.getContext();
document.getElementById("title").textContent = context.pageTitle;
}
getLocale()
同步读取当前 WebUI 语言。没有上下文时默认返回 zh-CN。
document.documentElement.lang = bridge.getLocale();
getI18n()
同步读取当前插件的完整 i18n 资源对象。一般优先用 t(),只有需要自定义遍历或调试时才直接读取。
console.log(Object.keys(bridge.getI18n()));
t(key, fallback)
按点分隔 key 从插件 i18n 中取文案。当前语言缺失时会尝试回退,仍缺失则返回 fallback。
saveButton.textContent = bridge.t("pages.settings.save", "Save");
onContext(handler)
监听上下文变化,返回取消监听函数。WebUI 切换语言时会触发该回调,所以需要响应语言切换的页面应在这里重新渲染。
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>。
const stats = await bridge.apiGet("stats", { limit: 20 });
后端注册示例:
context.register_web_api(
f"/{PLUGIN_NAME}/stats",
self.get_stats,
["GET"],
"Get stats",
)
apiPost(endpoint, body)
向插件后端发送 POST 请求,body 会作为 JSON 请求体发送,返回 Promise<data>。
await bridge.apiPost("settings/save", {
enabled: true,
threshold: 0.8,
});
upload(endpoint, file)
以 multipart/form-data 上传单个文件,字段名为 file,返回 Promise<data>。
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 可选;不传时会尝试使用响应头里的文件名。
await bridge.download("files/export", { format: "json" }, "export.json");
subscribeSSE(endpoint, handlers, params)
订阅插件后端 SSE,返回 Promise<subscriptionId>。handlers 可包含 onOpen、onMessage、onError。
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 订阅。
await bridge.unsubscribeSSE(subscriptionId);
页面卸载时建议清理订阅:
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。