feat: supports plugin to register custom pages (webui) (#5940)

* feat(plugin): add webui metadata schema for plugins

* feat(dashboard): serve plugin webui with scoped asset tokens

* feat(dashboard): add plugin webui page and extension entry actions

* test(dashboard): cover plugin webui auth and asset routing

* fix(dashboard): use aiofiles for non-blocking plugin webui assets

* fix(dashboard): streamline JWT extraction and validation for plugin webui paths

* fix(dashboard): harden plugin webui bridge and auth cookie security

* fix(dashboard): restore plugin webui bridge under sandbox iframe

* refactor(dashboard): apply plugin webui review improvements

* docs: 补充插件 WebUI 开发指南

* fix(plugin-webui): 统一 WebUI title 契约并修复桥接行为

* docs: 更新插件 WebUI 开发指南

* fix

* feat: Introduce Plugin Pages feature

- Added support for plugins to expose Dashboard pages via a `pages/` directory.
- Updated `PluginDetailPage.vue` to include a button for opening plugin pages.
- Refactored `useExtensionPage.js` to remove the deprecated `openPluginWebUI` function.
- Updated documentation to replace references from "Plugin WebUI" to "Plugin Pages".
- Created new documentation for Plugin Pages detailing structure, examples, and API usage.
- Removed the old Plugin WebUI documentation.
- Updated tests to reflect changes from Plugin WebUI to Plugin Pages, ensuring proper functionality and security checks.

* feat: 增强插件页面功能,添加返回按钮逻辑并更新测试用例

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
lxfight
2026-05-03 20:41:50 +08:00
committed by GitHub
parent 6eb8a51c70
commit fff9c8ee19
22 changed files with 2442 additions and 34 deletions

View File

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

View File

@@ -0,0 +1,151 @@
# Plugin Pages
AstrBot lets a plugin expose Dashboard pages by placing static assets under `pages/`. Each direct child directory is one Page:
```text
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-config.md). Plugin Pages are more suitable for complex forms, dashboards, logs, file transfer, SSE, and custom interaction flows.
## Minimal Frontend Example
`pages/bridge-demo/index.html`
```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`
```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:
```text
/api/plug/<plugin_name>/ping
```
The registered Web API route must include the plugin name as a prefix:
```python
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 context
- `getContext()`: Read the current context
- `apiGet(endpoint, params)`: Send a GET request
- `apiPost(endpoint, body)`: Send a POST request
- `upload(endpoint, file)`: Upload one file as `multipart/form-data`
- `download(endpoint, params, filename)`: Download a backend response
- `subscribeSSE(endpoint, handlers, params)`: Subscribe to SSE
- `unsubscribeSSE(subscriptionId)`: Cancel an SSE subscription
The current `ready()` context looks like this:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo"
}
```
`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.
## 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 `src` and `href`
- 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:
```text
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: SAMEORIGIN`
- `Content-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.html` exists and the plugin is enabled

View File

@@ -0,0 +1,151 @@
# 插件 Pages
AstrBot 支持插件通过 `pages/` 目录暴露 Dashboard 页面。`pages/` 下的每个一级子目录都是一个独立 Page
```text
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`](./plugin-config.md)。插件 Pages 更适合复杂表单、Dashboard、日志、文件上传下载、SSE 和自定义交互流程。
## 最小前端示例
`pages/bridge-demo/index.html`
```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`
```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 会转发到:
```text
/api/plug/<plugin_name>/ping
```
因此注册 Web API 时,路由必须带上插件名作为前缀:
```python
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()`: 读取当前上下文
- `apiGet(endpoint, params)`: 发送 GET 请求
- `apiPost(endpoint, body)`: 发送 POST 请求
- `upload(endpoint, file)`: 以 `multipart/form-data` 上传单个文件
- `download(endpoint, params, filename)`: 下载后端响应
- `subscribeSSE(endpoint, handlers, params)`: 订阅 SSE
- `unsubscribeSSE(subscriptionId)`: 取消 SSE 订阅
当前 `ready()` 上下文类似:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo"
}
```
`endpoint` 必须是插件内相对路径,不能为空,不能包含 `\`、URL scheme、query、hash也不能包含 `.``..` 路径片段。
## 静态资源路径规则
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 中:
```text
allow-scripts allow-forms allow-downloads
```
Page 不能直接访问 Dashboard cookies、LocalStorage 或同源 DOM也不能绕过 bridge 复用 Dashboard auth。
AstrBot 还会给资源响应添加安全头,包括:
- `X-Frame-Options: SAMEORIGIN`
- `Content-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` 是否存在,以及插件是否启用