* 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
Plugin Pages
AstrBot lets a plugin expose Dashboard pages by placing static assets under pages/. Each direct child directory is one Page:
astrbot_plugin_page_demo/
├─ main.py
└─ pages/
├─ bridge-demo/
│ ├─ index.html
│ ├─ app.js
│ ├─ style.css
│ └─ assets/
│ └─ logo.svg
└─ settings/
└─ index.html
AstrBot scans pages/<page_name>/index.html; directories without index.html are ignored.
If you only need a few editable settings, prefer _conf_schema.json. Plugin Pages are more suitable for complex forms, dashboards, logs, file transfer, SSE, and custom interaction flows.
Once Pages are registered, users can open the AstrBot WebUI Plugins page, click the plugin card to enter the plugin detail page, and then view and open the registered Pages from that detail page.
Minimal Frontend Example
pages/bridge-demo/index.html
<!doctype html>
<html lang="en">
<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);
});
You do not need to import the bridge SDK manually. AstrBot injects /api/plugin/page/bridge-sdk.js into returned HTML.
Register Backend APIs
When the frontend calls bridge.apiGet("ping"), the Dashboard forwards it to:
/api/plug/<plugin_name>/ping
The registered Web API route must include the plugin name as a prefix:
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
Inside a plugin Page, use window.AstrBotPluginPage directly:
ready(): Wait until the bridge is ready and return the contextgetContext(): Read the current contextgetLocale(): Read the current WebUI localegetI18n(): Read the current plugin i18n resourcest(key, fallback): Read text from plugin i18n resources by key, returning fallback when missingonContext(handler): Listen for context changes, such as rerendering after the WebUI locale changesapiGet(endpoint, params): Send a GET requestapiPost(endpoint, body): Send a POST requestupload(endpoint, file): Upload one file asmultipart/form-datadownload(endpoint, params, filename): Download a backend responsesubscribeSSE(endpoint, handlers, params): Subscribe to SSEunsubscribeSSE(subscriptionId): Cancel an SSE subscription
The current ready() context looks like this:
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {},
"isDark": false
}
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:
{
"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():
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:
<script src="/api/plugin/page/bridge-sdk.js"></script>
Light/Dark Theme Adaptation
When the user toggles light/dark mode in AstrBot, the theme state is automatically synced to Plugin Pages. The bridge SDK maintains a data-theme attribute on the <html> element:
- Light mode:
<html data-theme="light"> - Dark mode:
<html data-theme="dark">
CSS Adaptation
CSS variables are the recommended approach:
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
[data-theme="dark"] {
--bg: #1a1a1a;
--text: #e0e0e0;
}
body {
background: var(--bg);
color: var(--text);
}
AstrBot injects the data-theme attribute on the <html> tag server-side when serving HTML, so there is no initial flash.
Responding to Theme Changes in JavaScript
The isDark field in the context indicates whether dark mode is active. Use onContext() to listen for theme changes:
const bridge = window.AstrBotPluginPage;
function render() {
if (bridge.getContext()?.isDark) {
// JS logic for dark mode (e.g. chart filters)
}
}
await bridge.ready();
render();
bridge.onContext(render);
When the theme is toggled, the Dashboard sends the updated context to the iframe via the bridge. As long as the Page listens with onContext(), it will be notified.
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.
AstrBot rewrites:
- HTML
srcandhref - CSS
url(...) - JavaScript
import - JavaScript
export ... from - JavaScript dynamic
import()
Keep static assets on relative paths such as ./style.css and ./assets/logo.svg. Do not manually append asset_token, and do not rely on .. to escape the Page root directory.
If you build a SPA, prefer hash routing. The static asset server resolves real file paths; with history routing, refreshing a page requires an actual file to exist at that path.
Security Constraints
Plugin Pages run inside a restricted iframe:
allow-scripts allow-forms allow-downloads
The page cannot directly access Dashboard cookies, LocalStorage, or same-origin DOM, and it cannot bypass the bridge to reuse Dashboard auth directly.
AstrBot also adds security headers to asset responses, including:
X-Frame-Options: SAMEORIGINContent-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'Cache-Control: no-store
Debugging Tips
- 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.htmlexists and the plugin is enabled
Appendix: Bridge API Details
Start page scripts by keeping a bridge reference:
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.
const context = await bridge.ready();
console.log(context.pluginName, context.pageName, context.locale);
The context usually contains:
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {},
"isDark": false
}
getContext()
Synchronously reads the latest context. Use it after ready() or inside an onContext() callback.
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.
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.
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.
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.
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>.
const stats = await bridge.apiGet("stats", { limit: 20 });
Backend registration example:
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>.
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>.
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.
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.
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.
await bridge.unsubscribeSSE(subscriptionId);
Clean up subscriptions when the page unloads:
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.