mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
refactor(core): migrate backend backbone from Quart to FastAPI and introduce more OpenAPI (#8688)
* refactor: migrate to fastapi * structure refactor * fix: pyright fix * refactor: improve error handling and public messages in plugin services * feat(api): refactor API client integration and enhance request handling - Updated API client configuration to use a dedicated HTTP client. - Introduced utility functions for generating options, queries, and form data for API requests. - Refactored multiple API methods to utilize the new utility functions for improved consistency and readability. - Renamed types for clarity and updated import statements accordingly. feat(docs): add script to update OpenAPI JSON from YAML spec - Created a Python script to convert OpenAPI YAML specification to JSON format. - The script supports customizable input and output paths. - Ensured the script handles directory creation for output paths and validates the YAML structure. * fix * feat(auth): implement rate limiting for v1 login endpoint and enhance request handling * Refactor dashboard API routers to use legacy_router for backward compatibility - Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime. - Updated route definitions to ensure existing endpoints remain functional under the new router structure. - Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins. - Added a test case to validate the functionality of the new Quart request context handling in plugin extensions. * chore: remove cli test * fix: update dashboard tests for fastapi migration * chore: satisfy ruff checks * fix: update openapi api key scopes * fix: sync config scope chip selection * fix: restore quart dependency * docs: clarify quart plugin api compatibility * docs: update openapi scope documentation * fix: use singular skill openapi scope * fix: hide update service exception details * fix: address fastapi review comments * fix: address dashboard review findings * docs: revert unrelated package deployment changes * docs: update agent api generation guidance * feat: add plugin page web api helpers * docs: add plugin page bridge demo * fix: type plugin upload files * fix: stabilize plugin page uploads * fix: type plugin web request proxy * docs: remove plugin page docs example * fix: authenticate plugin page SSE bridge
This commit is contained in:
@@ -26,19 +26,30 @@ X-API-Key: abk_xxx
|
||||
- `POST /api/v1/chat`:请求体必须包含 `username`
|
||||
- `GET /api/v1/chat/sessions`:查询参数必须包含 `username`
|
||||
|
||||
本地 OpenAPI 描述文件地址为 `http://localhost:6185/api/v1/openapi.json`,交互式文档地址为 `http://localhost:6185/api/v1/docs`。
|
||||
|
||||
## Scope 权限说明
|
||||
|
||||
创建 API Key 时可配置 `scopes`。每个 scope 控制可访问的接口范围:
|
||||
|
||||
| Scope | 作用 | 可访问接口 |
|
||||
| --- | --- | --- |
|
||||
| `chat` | 调用对话能力、查询对话会话 | `POST /api/v1/chat`、`GET /api/v1/chat/sessions` |
|
||||
| `config` | 获取可用配置文件列表 | `GET /api/v1/configs` |
|
||||
| `file` | 上传附件文件,获取 `attachment_id` | `POST /api/v1/file` |
|
||||
| `bot` | 管理机器人/平台配置 | `GET /api/v1/bot-types`、`GET/POST /api/v1/bots`、`PATCH /api/v1/bots/enabled` |
|
||||
| `provider` | 管理模型提供商和提供商源 | `GET/POST /api/v1/providers`、`GET/PUT/DELETE /api/v1/provider-sources/by-id` |
|
||||
| `persona` | 管理人格和人格文件夹 | `GET/POST /api/v1/personas`、`GET/POST /api/v1/persona-folders` |
|
||||
| `im` | 主动发 IM 消息、查询 bot/platform 列表 | `POST /api/v1/im/message`、`GET /api/v1/im/bots` |
|
||||
| `config` | 管理配置文件、系统配置和通用配置。该 scope 同时包含 `bot` 和 `provider` 访问权限。 | `GET /api/v1/configs`、`GET/PUT /api/v1/system-config`、`GET/POST /api/v1/config-profiles` |
|
||||
| `chat` | 调用对话能力、查询对话会话 | `POST /api/v1/chat`、`GET /api/v1/chat/sessions` |
|
||||
| `plugin` | 管理插件、插件配置、插件源和插件市场 | `GET /api/v1/plugins`、`GET/PUT /api/v1/plugins/config`、`POST /api/v1/plugins/install/url` |
|
||||
| `mcp` | 管理 MCP 服务器配置和服务端同步 | `GET/POST /api/v1/mcp/servers`、`PATCH /api/v1/mcp/servers/{server_name}/enabled`、`POST /api/v1/mcp/providers/modelscope/sync` |
|
||||
| `skill` | 管理 Skills、Skill 压缩包、Skill 文件和 Shipyard Neo Skill 流程 | `GET/POST /api/v1/skills`、`PUT /api/v1/skills/{skill_name}/files/{file_path}`、`POST /api/v1/skills/neo/sync` |
|
||||
|
||||
如果 API Key 未包含目标接口所需 scope,请求会返回 `403 Insufficient API key scope`。
|
||||
|
||||
`config` 是较大的管理 scope。创建 API Key 时如果包含 `config`,AstrBot 会同时授予该 Key `config`、`bot` 和 `provider` 访问权限。WebUI 的勾选逻辑也会体现这个依赖关系:选中 `config` 会同时选中 `bot` 和 `provider`;取消选中 `bot` 或 `provider` 时,会同步取消 `config`。
|
||||
|
||||
当前开发者 API Key 仅开放以上 9 个 scope。`file`、`tool`、`skills`、`kb`、`data`、`system` 暂不支持作为开发者 API Key scope。`/api/v1/skills/*` 接口使用单数 `skill` scope,不使用复数 `skills`。相关接口可能仍出现在 `/api/v1` 文档中,但只有 scope 属于上表支持范围时才对开发者 API Key 开放。
|
||||
|
||||
## 常用接口
|
||||
|
||||
**对话类**
|
||||
@@ -49,9 +60,19 @@ X-API-Key: abk_xxx
|
||||
- `GET /api/v1/chat/sessions`:分页获取指定 `username` 的会话
|
||||
- `GET /api/v1/configs`:获取可用配置文件列表
|
||||
|
||||
**文件上传**
|
||||
**机器人和模型提供商**
|
||||
|
||||
- `POST /api/v1/file`:上传附件
|
||||
- `GET /api/v1/bots`:获取机器人/平台配置列表
|
||||
- `POST /api/v1/bots`:创建机器人/平台配置
|
||||
- `GET /api/v1/providers`:获取模型提供商配置列表
|
||||
- `GET /api/v1/provider-sources`:获取提供商源配置列表
|
||||
|
||||
**人格、插件、MCP 和 Skills**
|
||||
|
||||
- `GET /api/v1/personas`:获取人格列表
|
||||
- `GET /api/v1/plugins`:获取插件列表
|
||||
- `GET /api/v1/mcp/servers`:获取 MCP 服务器列表
|
||||
- `GET /api/v1/skills`:获取 Skills 列表
|
||||
|
||||
**IM 消息发送**
|
||||
|
||||
@@ -100,7 +121,7 @@ X-API-Key: abk_xxx
|
||||
|
||||
说明:
|
||||
|
||||
- `attachment_id` 来自 `POST /api/v1/file` 上传结果。
|
||||
- `attachment_id` 来自已存在的附件记录。开发者 API Key 当前不能使用 `POST /api/v1/file` 上传附件。
|
||||
- `reply` 不能单独作为唯一内容,至少需要一个有实际内容的段(如 `plain/image/file/...`)。
|
||||
- 仅 `reply` 或空内容会返回错误。
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# 插件 Pages
|
||||
|
||||
AstrBot 支持插件通过 `pages/` 目录暴露 Dashboard 页面。`pages/` 下的每个一级子目录都是一个独立 Page:
|
||||
插件 Pages 允许插件在 AstrBot WebUI 中提供自己的页面。页面文件放在插件目录的 `pages/` 下,由 Dashboard 以受限 iframe 的方式加载;页面里的脚本通过 `window.AstrBotPluginPage` bridge 和 Dashboard 通信,再由 Dashboard 转发到插件注册的后端 Web API。
|
||||
|
||||
如果只是让用户填写少量配置项,优先使用 [`_conf_schema.json`](./plugin-config.md)。Pages 更适合复杂表单、运行状态面板、日志查看、文件上传下载、SSE 实时流、图表和其他需要自定义交互的场景。
|
||||
|
||||
## 目录结构
|
||||
|
||||
`pages/` 下的每个一级子目录是一个独立 Page。AstrBot 只扫描 `pages/<page_name>/index.html`,没有 `index.html` 的目录会被忽略。
|
||||
|
||||
```text
|
||||
astrbot_plugin_page_demo/
|
||||
@@ -16,13 +22,65 @@ astrbot_plugin_page_demo/
|
||||
└─ index.html
|
||||
```
|
||||
|
||||
AstrBot 会扫描 `pages/<page_name>/index.html`;没有 `index.html` 的目录会被忽略。
|
||||
`page_name` 应使用简单目录名,例如 `settings`、`bridge-demo`。不要使用空目录名、`.`、`..`、以 `.` 开头的目录名,或包含 `/`、`\` 的名称。
|
||||
|
||||
如果只是让用户填写几个配置项,优先使用 [`_conf_schema.json`](./plugin-config.md)。插件 Pages 更适合复杂表单、Dashboard、日志、文件上传下载、SSE 和自定义交互流程。
|
||||
用户可以在 WebUI 的插件页点击插件卡片进入插件详情页,然后打开插件声明的 Pages。
|
||||
|
||||
一旦注册了 Pages,用户可以在:AstrBot WebUI 插件页中的插件卡片中,点击插件卡片进入插件详细页面,在插件详细页面中可以看到并进入注册的 Pages。
|
||||
## 开发流程
|
||||
|
||||
## 最小前端示例
|
||||
1. 在插件目录下创建 `pages/<page_name>/index.html`。
|
||||
2. 在 Page 中通过 `window.AstrBotPluginPage` bridge 调用后端能力。
|
||||
3. 在 `main.py` 中使用 `context.register_web_api()` 注册插件后端 API。
|
||||
4. 后端 handler 使用 `astrbot.api.web` 读取请求并返回响应。
|
||||
5. 新增或删除 Page 目录后重载插件;修改静态资源通常刷新 Page 即可。
|
||||
|
||||
## 最小完整示例
|
||||
|
||||
### 后端
|
||||
|
||||
插件后端推荐使用 `astrbot.api.web`,不要把 FastAPI、Starlette 或 Quart 的原始请求对象作为插件公共 API 暴露给自己的业务代码。
|
||||
|
||||
```python
|
||||
from astrbot.api.star import Context, Star
|
||||
from astrbot.api.web import error_response, json_response, request
|
||||
|
||||
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",
|
||||
)
|
||||
context.register_web_api(
|
||||
f"/{PLUGIN_NAME}/settings/save",
|
||||
self.save_settings,
|
||||
["POST"],
|
||||
"Save Page settings",
|
||||
)
|
||||
|
||||
async def page_ping(self):
|
||||
limit = request.query.get("limit", 20, type=int)
|
||||
return json_response(
|
||||
{
|
||||
"message": "pong",
|
||||
"limit": limit,
|
||||
"username": request.username,
|
||||
}
|
||||
)
|
||||
|
||||
async def save_settings(self):
|
||||
payload = await request.json(default={})
|
||||
if not isinstance(payload.get("enabled"), bool):
|
||||
return error_response("enabled must be a boolean")
|
||||
return json_response({"saved": True})
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
`pages/bridge-demo/index.html`
|
||||
|
||||
@@ -52,62 +110,218 @@ const context = await bridge.ready();
|
||||
output.textContent = JSON.stringify(context, null, 2);
|
||||
|
||||
document.getElementById("ping").addEventListener("click", async () => {
|
||||
const result = await bridge.apiGet("ping");
|
||||
const result = await bridge.apiGet("ping", { limit: 20 });
|
||||
output.textContent = JSON.stringify(result, null, 2);
|
||||
});
|
||||
```
|
||||
|
||||
这里不需要手动引入 bridge SDK。AstrBot 会在返回的 HTML 里自动插入 `/api/plugin/page/bridge-sdk.js`。
|
||||
不需要手动引入 bridge SDK。AstrBot 返回 HTML 时会自动插入 `/api/plugin/page/bridge-sdk.js`。如果内联脚本必须同步访问 `window.AstrBotPluginPage`,请把脚本改成外部 module 文件,或在自己的脚本前显式引入:
|
||||
|
||||
## 注册后端 API
|
||||
|
||||
前端调用 `bridge.apiGet("ping")` 时,Dashboard 会转发到:
|
||||
|
||||
```text
|
||||
/api/plug/<plugin_name>/ping
|
||||
```html
|
||||
<script src="/api/plugin/page/bridge-sdk.js"></script>
|
||||
```
|
||||
|
||||
因此注册 Web API 时,路由必须带上插件名作为前缀:
|
||||
## 后端 Web API
|
||||
|
||||
### 路由注册
|
||||
|
||||
使用 `context.register_web_api(route, view_handler, methods, desc)` 注册插件 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"})
|
||||
context.register_web_api(
|
||||
f"/{PLUGIN_NAME}/items/<item_id>",
|
||||
self.get_item,
|
||||
["GET"],
|
||||
"Get item",
|
||||
)
|
||||
```
|
||||
|
||||
路由需要包含插件名作为前缀。Page 端的 bridge endpoint 不需要包含插件名:
|
||||
|
||||
```js
|
||||
await bridge.apiGet("items/123");
|
||||
```
|
||||
|
||||
Dashboard 会把它转发到:
|
||||
|
||||
```text
|
||||
/api/v1/plugins/extensions/<plugin_name>/items/123
|
||||
```
|
||||
|
||||
注册路由 `/<plugin_name>/items/<item_id>` 会匹配该请求,`item_id` 作为 handler 的关键字参数传入:
|
||||
|
||||
```python
|
||||
async def get_item(self, item_id: str):
|
||||
return json_response({"item_id": item_id})
|
||||
```
|
||||
|
||||
支持的动态片段:
|
||||
|
||||
- `<name>`:匹配单个路径片段。
|
||||
- `<path:name>`:匹配后续多级路径。
|
||||
|
||||
### 请求对象
|
||||
|
||||
推荐导入:
|
||||
|
||||
```python
|
||||
from astrbot.api.web import request
|
||||
```
|
||||
|
||||
`request` 是当前请求的上下文代理,只能在插件 Web API handler 执行期间访问。常用字段和方法:
|
||||
|
||||
| API | 说明 |
|
||||
| --- | --- |
|
||||
| `request.method` | HTTP 方法,例如 `GET`、`POST` |
|
||||
| `request.path` | 当前 Dashboard API 路径 |
|
||||
| `request.plugin_name` | 从扩展路径解析出的插件名 |
|
||||
| `request.username` | 当前 Dashboard 用户名,可能为 `None` |
|
||||
| `request.headers` | 请求头 |
|
||||
| `request.cookies` | 请求 cookies |
|
||||
| `request.content_type` | 请求 Content-Type |
|
||||
| `request.client_host` | 客户端地址 |
|
||||
| `request.path_params` | 路由动态参数字典 |
|
||||
| `request.query` | query 参数,支持 `get()` 和 `getlist()` |
|
||||
| `await request.body()` | 原始请求体 bytes |
|
||||
| `await request.json(default={})` | JSON 请求体,解析失败返回 default |
|
||||
| `await request.form()` | 表单字段,不含上传文件 |
|
||||
| `await request.files()` | 上传文件 |
|
||||
|
||||
query 参数使用示例:
|
||||
|
||||
```python
|
||||
limit = request.query.get("limit", 20, type=int)
|
||||
tags = request.query.getlist("tag")
|
||||
```
|
||||
|
||||
JSON 请求体使用示例:
|
||||
|
||||
```python
|
||||
payload = await request.json(default={})
|
||||
enabled = bool(payload.get("enabled"))
|
||||
```
|
||||
|
||||
文件上传使用示例:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path
|
||||
from astrbot.api.web import PluginUploadFile, error_response, json_response, request
|
||||
|
||||
|
||||
async def import_file(self):
|
||||
form = await request.form()
|
||||
files = await request.files()
|
||||
upload: PluginUploadFile | None = files.get("file")
|
||||
if not isinstance(upload, PluginUploadFile):
|
||||
return error_response("missing file")
|
||||
|
||||
target_dir = (
|
||||
Path(get_astrbot_plugin_data_path())
|
||||
/ (request.plugin_name or "unknown_plugin")
|
||||
/ "imports"
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = target_dir / Path(upload.filename).name
|
||||
await upload.save(target)
|
||||
return json_response(
|
||||
{
|
||||
"filename": upload.filename,
|
||||
"content_type": upload.content_type,
|
||||
"tag": form.get("tag"),
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
`request.form()` 和 `request.files()` 会缓存解析结果,可以在同一个 handler 中各调用一次。
|
||||
|
||||
### 响应对象
|
||||
|
||||
推荐从 `astrbot.api.web` 导入响应 helper:
|
||||
|
||||
```python
|
||||
from astrbot.api.web import (
|
||||
error_response,
|
||||
file_response,
|
||||
json_response,
|
||||
stream_response,
|
||||
)
|
||||
```
|
||||
|
||||
JSON 响应:
|
||||
|
||||
```python
|
||||
return json_response({"saved": True})
|
||||
```
|
||||
|
||||
错误响应:
|
||||
|
||||
```python
|
||||
return error_response("invalid threshold", status_code=400)
|
||||
```
|
||||
|
||||
文件下载响应:
|
||||
|
||||
```python
|
||||
return file_response(
|
||||
export_path,
|
||||
filename="export.json",
|
||||
content_type="application/json",
|
||||
)
|
||||
```
|
||||
|
||||
SSE 响应:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
from astrbot.api.web import stream_response
|
||||
|
||||
|
||||
async def stream_events(self):
|
||||
async def events():
|
||||
yield f"data: {json.dumps({'state': 'started'})}\n\n"
|
||||
yield f"data: {json.dumps({'state': 'done'})}\n\n"
|
||||
|
||||
return stream_response(events())
|
||||
```
|
||||
|
||||
直接返回 `dict`、`list`、`(body, status_code)` 或底层 Response 对象仍然可用;文档和新插件推荐优先使用 `astrbot.api.web` helper,让插件代码和 Dashboard 内部框架解耦。
|
||||
|
||||
### Quart 兼容
|
||||
|
||||
为了兼容旧插件,通过 `context.register_web_api()` 注册的 handler 仍会进入 Quart 兼容请求上下文。旧代码可以继续使用:
|
||||
|
||||
```python
|
||||
from quart import jsonify, request
|
||||
```
|
||||
|
||||
新插件和新文档推荐使用:
|
||||
|
||||
```python
|
||||
from astrbot.api.web import json_response, request
|
||||
```
|
||||
|
||||
不要在同一个 handler 中混用两个 `request` 代理,迁移时按 handler 逐步替换即可。
|
||||
|
||||
## Bridge API
|
||||
|
||||
插件 Page 中可直接使用 `window.AstrBotPluginPage`:
|
||||
Page iframe 不能直接访问 Dashboard cookies、LocalStorage 或父页面 DOM。页面脚本必须通过 `window.AstrBotPluginPage` bridge 调用后端和读取上下文。
|
||||
|
||||
- `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` 上传单个文件
|
||||
- `download(endpoint, params, filename)`: 下载后端响应
|
||||
- `subscribeSSE(endpoint, handlers, params)`: 订阅 SSE
|
||||
- `unsubscribeSSE(subscriptionId)`: 取消 SSE 订阅
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
```
|
||||
|
||||
当前 `ready()` 上下文类似:
|
||||
### 上下文
|
||||
|
||||
`ready()` 等待父页面发送初始上下文,返回 `Promise<context>`。页面初始化时应先等待它。
|
||||
|
||||
```js
|
||||
const context = await bridge.ready();
|
||||
```
|
||||
|
||||
上下文通常包含:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -121,11 +335,211 @@ class MyPlugin(Star):
|
||||
}
|
||||
```
|
||||
|
||||
`endpoint` 必须是插件内相对路径,不能为空,不能包含 `\`、URL scheme、query、hash,也不能包含 `.` 或 `..` 路径片段。
|
||||
上下文相关 API:
|
||||
|
||||
| API | 返回值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `ready()` | `Promise<context>` | 等待 bridge 就绪并返回初始上下文 |
|
||||
| `getContext()` | `context \| null` | 同步读取最近一次上下文 |
|
||||
| `getLocale()` | `string` | 当前 WebUI 语言,默认 `zh-CN` |
|
||||
| `getI18n()` | `object` | 当前插件 i18n 资源 |
|
||||
| `t(key, fallback)` | `string` | 按点分隔 key 读取翻译,缺失时返回 fallback |
|
||||
| `onContext(handler)` | `() => void` | 监听上下文变化,返回取消监听函数 |
|
||||
|
||||
监听语言或主题变化:
|
||||
|
||||
```js
|
||||
function render() {
|
||||
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
|
||||
document.getElementById("locale").textContent = bridge.getLocale();
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
|
||||
const off = bridge.onContext(render);
|
||||
window.addEventListener("beforeunload", off);
|
||||
```
|
||||
|
||||
### 请求和返回值规则
|
||||
|
||||
`apiGet`、`apiPost`、`upload`、`download`、`subscribeSSE` 的 `endpoint` 都是插件内相对路径,例如 `stats`、`settings/save`、`files/export`。推荐不要以 `/` 开头;当前 bridge 会为了兼容旧写法去掉开头的 `/`。
|
||||
|
||||
`endpoint` 不能是空字符串,不能包含 `\`、URL scheme、query、hash,也不能包含空路径片段、`.` 或 `..`。
|
||||
|
||||
不要把 query string 拼进 endpoint:
|
||||
|
||||
```js
|
||||
await bridge.apiGet("stats", { limit: 20 });
|
||||
```
|
||||
|
||||
bridge 对 JSON 类请求的返回值有一个兼容规则:
|
||||
|
||||
- 如果后端返回 `{ "status": "ok", "data": value }`,Promise resolve 为 `value`。
|
||||
- 如果后端返回普通 JSON,例如 `{ "message": "pong" }`,Promise resolve 为完整 JSON。
|
||||
- 如果后端返回 `{ "status": "error", "message": "..." }`,或 HTTP 请求失败,Promise reject 为 `Error`。
|
||||
|
||||
因此 Page-only API 推荐直接返回业务 JSON:
|
||||
|
||||
```python
|
||||
return json_response({"message": "pong"})
|
||||
```
|
||||
|
||||
需要表达错误时使用:
|
||||
|
||||
```python
|
||||
return error_response("missing file", status_code=400)
|
||||
```
|
||||
|
||||
Page 端统一捕获错误:
|
||||
|
||||
```js
|
||||
try {
|
||||
await bridge.apiPost("settings/save", { enabled: true });
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
```
|
||||
|
||||
### `apiGet(endpoint, params)`
|
||||
|
||||
发送 GET 请求。`params` 会作为 query 参数传递。
|
||||
|
||||
```js
|
||||
const stats = await bridge.apiGet("stats", { limit: 20, tag: "today" });
|
||||
```
|
||||
|
||||
后端读取:
|
||||
|
||||
```python
|
||||
async def stats(self):
|
||||
limit = request.query.get("limit", 20, type=int)
|
||||
tag = request.query.get("tag")
|
||||
return json_response({"limit": limit, "tag": tag})
|
||||
```
|
||||
|
||||
### `apiPost(endpoint, body)`
|
||||
|
||||
发送 POST JSON 请求。
|
||||
|
||||
```js
|
||||
const result = await bridge.apiPost("settings/save", {
|
||||
enabled: true,
|
||||
threshold: 0.8,
|
||||
});
|
||||
```
|
||||
|
||||
后端读取:
|
||||
|
||||
```python
|
||||
async def save_settings(self):
|
||||
payload = await request.json(default={})
|
||||
return json_response({"saved": True, "enabled": payload.get("enabled")})
|
||||
```
|
||||
|
||||
### `upload(endpoint, file)`
|
||||
|
||||
以 `multipart/form-data` 上传单个文件,字段名固定为 `file`。
|
||||
|
||||
```js
|
||||
const input = document.querySelector("input[type=file]");
|
||||
const file = input.files[0];
|
||||
const result = await bridge.upload("files/import", file);
|
||||
```
|
||||
|
||||
后端读取:
|
||||
|
||||
```python
|
||||
from astrbot.api.web import PluginUploadFile, error_response, json_response, request
|
||||
|
||||
|
||||
async def import_file(self):
|
||||
files = await request.files()
|
||||
upload: PluginUploadFile | None = files.get("file")
|
||||
if not isinstance(upload, PluginUploadFile):
|
||||
return error_response("missing file", status_code=400)
|
||||
return json_response({"filename": upload.filename})
|
||||
```
|
||||
|
||||
如果还需要普通字段,请单独使用 `apiPost` 传配置,或在后端根据 query 参数区分导入行为。当前 bridge 的 `upload()` 只发送一个文件。
|
||||
|
||||
### `download(endpoint, params, filename)`
|
||||
|
||||
请求插件后端文件接口并触发浏览器下载。`params` 会作为 query 参数发送;`filename` 可选,缺省时 bridge 会尝试从响应头读取文件名。
|
||||
|
||||
```js
|
||||
await bridge.download("files/export", { format: "json" }, "export.json");
|
||||
```
|
||||
|
||||
后端返回文件:
|
||||
|
||||
```python
|
||||
async def export_file(self):
|
||||
fmt = request.query.get("format", "json")
|
||||
return file_response(
|
||||
export_path,
|
||||
filename=f"export.{fmt}",
|
||||
content_type="application/json",
|
||||
)
|
||||
```
|
||||
|
||||
`download()` resolve 为:
|
||||
|
||||
```json
|
||||
{ "filename": "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.raw` 是原始字符串;如果内容是 JSON 字符串,`event.parsed` 会自动解析,否则等于原始字符串。`event.eventType` 对应 SSE 的 `event:` 字段,未设置时为 `message`。
|
||||
|
||||
后端必须返回 `text/event-stream`:
|
||||
|
||||
```python
|
||||
async def events(self):
|
||||
async def stream():
|
||||
yield 'data: {"message": "ready"}\n\n'
|
||||
|
||||
return stream_response(stream())
|
||||
```
|
||||
|
||||
取消订阅:
|
||||
|
||||
```js
|
||||
await bridge.unsubscribeSSE(subscriptionId);
|
||||
```
|
||||
|
||||
页面卸载时建议清理:
|
||||
|
||||
```js
|
||||
window.addEventListener("beforeunload", () => {
|
||||
bridge.unsubscribeSSE(subscriptionId);
|
||||
});
|
||||
```
|
||||
|
||||
## Page 国际化
|
||||
|
||||
插件 Page 复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/<locale>.json` 增加 `pages.<page_name>` 即可:
|
||||
插件 Pages 复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/<locale>.json` 增加 `pages.<page_name>`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -140,20 +554,15 @@ class MyPlugin(Star):
|
||||
}
|
||||
```
|
||||
|
||||
`title` 会用于 WebUI 外壳标题和插件详情页的 Page 组件名称,`description` 会用于插件详情页的 Page 组件描述。
|
||||
|
||||
在 Page 内部使用 `t()` 渲染文案,并用 `onContext()` 响应语言切换:
|
||||
`title` 用于 WebUI 外壳标题和插件详情页的 Page 组件名称;`description` 用于插件详情页的 Page 组件描述。Page 内部使用 `bridge.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();
|
||||
@@ -161,24 +570,14 @@ 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 切换亮色/暗色模式时,会自动将主题状态同步给插件 Page。bridge SDK 会在 `<html>` 元素上维护 `data-theme` 属性:
|
||||
AstrBot 会把当前主题同步给插件 Page。bridge SDK 会维护 `<html>` 的 `data-theme` 属性:
|
||||
|
||||
- 亮色模式:`<html data-theme="light">`
|
||||
- 暗色模式:`<html data-theme="dark">`
|
||||
|
||||
> 选择「跟随系统」时,AstrBot 会根据系统当前色彩模式自动决定主题,插件 Page 收到的 `data-theme` 值始终为 `light` 或 `dark`,无需额外处理。
|
||||
|
||||
### CSS 适配
|
||||
选择“跟随系统”时,Page 收到的值仍然是 `light` 或 `dark`。
|
||||
|
||||
推荐使用 CSS 变量:
|
||||
|
||||
@@ -199,33 +598,21 @@ body {
|
||||
}
|
||||
```
|
||||
|
||||
AstrBot 在服务端返回 HTML 时,会自动在 `<html>` 标签上注入 `data-theme` 属性,不会出现初始闪烁。
|
||||
服务端返回 HTML 时会预先注入 `data-theme`,减少初始闪烁。需要在 JavaScript 中响应主题变化时,读取 `bridge.getContext()?.isDark` 并监听 `onContext()`。
|
||||
|
||||
### JavaScript 响应主题变化
|
||||
## 静态资源路径
|
||||
|
||||
上下文中的 `isDark` 字段表示当前是否为暗色模式。通过 `onContext()` 可以监听主题切换:
|
||||
正常使用相对路径即可:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
|
||||
function render() {
|
||||
if (bridge.getContext()?.isDark) {
|
||||
// 暗色模式下的 JS 逻辑(如图表滤镜等)
|
||||
}
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
bridge.onContext(render);
|
||||
```html
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<script type="module" src="./app.js"></script>
|
||||
<img src="./assets/logo.svg" alt="" />
|
||||
```
|
||||
|
||||
切换亮暗模式时,Dashboard 会通过 bridge 将更新后的上下文发送给 iframe;只要 Page 监听了 `onContext()`,就会收到通知。
|
||||
AstrBot 会重写相对资源路径并追加短期 `asset_token`。不要手动拼接 `/api/plugin/page/content/...`,不要自行追加 `asset_token`,也不要依赖 `..` 逃逸 Page 根目录。
|
||||
|
||||
## 静态资源路径规则
|
||||
|
||||
AstrBot 会重写相对资源路径,并自动补上短期 `asset_token`。你只需要正常写相对路径,不要自己拼接 `/api/plugin/page/content/...`。
|
||||
|
||||
AstrBot 会重写:
|
||||
会被重写的资源引用包括:
|
||||
|
||||
- HTML `src` 和 `href`
|
||||
- CSS `url(...)`
|
||||
@@ -233,9 +620,7 @@ AstrBot 会重写:
|
||||
- JavaScript `export ... from`
|
||||
- JavaScript 动态 `import()`
|
||||
|
||||
建议把静态资源写成 `./style.css`、`./assets/logo.svg` 这类相对路径。不要手动追加 `asset_token`,也不要依赖 `..` 逃逸 Page 根目录。
|
||||
|
||||
如果你构建 SPA,建议使用 hash routing。静态资源服务按真实文件路径解析;history routing 刷新页面时需要对应路径上真的存在文件。
|
||||
如果构建 SPA,建议使用 hash routing。静态资源服务按真实文件路径解析;history routing 刷新页面时需要对应路径上真的存在文件。
|
||||
|
||||
## 安全约束
|
||||
|
||||
@@ -245,198 +630,23 @@ AstrBot 会重写:
|
||||
allow-scripts allow-forms allow-downloads
|
||||
```
|
||||
|
||||
Page 不能直接访问 Dashboard cookies、LocalStorage 或同源 DOM,也不能绕过 bridge 复用 Dashboard auth。
|
||||
Page 不能直接访问 Dashboard cookies、LocalStorage 或父页面 DOM,也不能绕过 bridge 复用 Dashboard auth。所有需要 Dashboard 身份的操作都应该走 bridge。
|
||||
|
||||
AstrBot 还会给资源响应添加安全头,包括:
|
||||
资源响应会带上安全头,包括:
|
||||
|
||||
- `X-Frame-Options: SAMEORIGIN`
|
||||
- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'`
|
||||
- `Cache-Control: no-store`
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
|
||||
后端 handler 仍然要验证输入。不要信任 Page 传来的路径、文件名、格式或数值范围;文件落盘时应使用安全目录,并对文件名做白名单或重新命名。
|
||||
|
||||
## 调试建议
|
||||
|
||||
- 新增或删除 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": {},
|
||||
"isDark": false
|
||||
}
|
||||
```
|
||||
|
||||
### `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`。
|
||||
- Page 没出现:检查 `pages/<page_name>/index.html` 是否存在、插件是否启用、插件详情页是否已刷新。
|
||||
- bridge 不存在:确认脚本在 bridge SDK 注入之后运行;推荐使用外部 `type="module"` 脚本。
|
||||
- API 未匹配:确认注册路由包含插件名前缀,例如 `/{PLUGIN_NAME}/stats`,而 Page 端 endpoint 是 `stats`。
|
||||
- query 或 JSON 为空:GET 参数放到 `apiGet(endpoint, params)`,POST JSON 放到 `apiPost(endpoint, body)`。
|
||||
- 文件上传为空:`upload()` 字段名固定为 `file`,后端用 `(await request.files()).get("file")` 读取。
|
||||
- SSE 没消息:确认后端响应是 `text/event-stream`,每条消息以空行结尾,例如 `data: ...\n\n`。
|
||||
- SSE 401:不要在 Page 中直接 `new EventSource("/api/v1/...")`,原生 `EventSource` 不能携带 `Authorization` header;请通过 `bridge.subscribeSSE()` 调用。
|
||||
|
||||
Reference in New Issue
Block a user