Console 页面与布局
插件不仅能往群里发消息,还能给 Remote Console 贡献完整的 React 页面。Sandbox 适配器的 /sandbox 聊天页就是这样来的。
和命令一样,页面放在约定目录 pages/ 里,文件会被自动发现,并编译成浏览器能加载的模块。
写第一个页面
// pages/index/index.tsx
import { definePage } from '@zhin.js/console-contract';
export const meta = definePage({
title: '我的插件',
icon: 'Box',
order: 10,
});
export default function MyPluginPage() {
return <div>Hello from my plugin</div>;
}- 默认导出一个 React 组件,就是页面内容。
- 命名导出
meta声明标题、图标和排序。 - 只有
pages/<name>/index.tsx(或index.ts)是页面,同目录的其他文件可以作为组件和辅助模块被引用。
启动 Bot 后,打开 Console 就能在导航里看到这个页面。
页面元数据
meta 的字段都可选,写了未知字段时 definePage 会报错:
| 字段 | 默认 | 说明 |
|---|---|---|
title | 由目录名生成(foo-bar → Foo Bar) | 导航与页头标题 |
icon | 无 | lucide 风格的图标名,如 'Box'、'Workflow' |
order | 100 | 导航排序,越小越靠前 |
hideInNav | false | 不在导航中显示 |
requiredPermissions | [] | 访问所需权限 |
requiredRoles | [] | 访问所需角色 |
页面的地址
地址 = 插件路径 + 页面名:
| 文件 | 所属插件 | 地址 |
|---|---|---|
pages/index/index.tsx | sandbox | /sandbox |
pages/workroom/index.tsx | 你的 Bot(root) | /p-workroom |
pages/index/index.tsx | 你的 Bot(root) | / |
index 对应插件路径本身,其他页面对应 p-<name>。两个页面算出同一个地址时,启动会报错。规则实现见 packages/console/plugin-contract/src/page.ts 的 pageRoute。
自定义导航和页脚
pages/ 下有两个保留名字:
pages/nav/index.tsx:导航区pages/footer/index.tsx:页脚区
默认导出一个 React 组件即可,不需要 meta。同一位置同时有 .ts 和 .tsx 时以 .tsx 为准。npm create zhin-app 生成的项目里已经带了这两个文件作为示例。
例子:Sandbox 聊天页
Sandbox 适配器(plugins/adapters/sandbox)同时提供了一个适配器和一个页面,它的 package.json#zhin 声明了两个 Feature:
{
"zhin": {
"protocol": 1,
"type": "plugin",
"entry": "./plugin.ts",
"runtime": "trusted",
"features": [
{ "package": "@zhin.js/adapter", "api": "^1.0.0" },
{ "package": "@zhin.js/page", "api": "^1.0.0" }
]
}
}@zhin.js/adapter发现adapters/下的适配器,提供 WebSocket/sandbox连接。@zhin.js/page发现pages/index/index.tsx,Console 里因此出现/sandbox聊天页。
页面里的 SandboxChat 组件通过 WebSocket 连接 /sandbox(地址与 token 见 pages/sandboxTransport.ts)。在页面里发一条消息,等价于真实平台收到一条消息:同样会经过中间件、命令匹配和 AI。所以不接任何真实平台也能完整调试命令、组件渲染和 Agent。Sandbox 会直接显示出站的 HTML 内容,不转成图片或文字,见 中间件与组件。
插件入口本身什么都不用写,页面和适配器都是自动发现的:
// plugins/adapters/sandbox/plugin.ts
import { definePlugin } from 'zhin.js';
export default definePlugin({
name: 'sandbox',
metadata: { displayName: 'Sandbox Adapter' },
});它是怎么工作的
页面文件运行在浏览器里,所以不会被 Node 直接加载,而是交给客户端构建器(TypeScriptClientBuilder,packages/console/pagemanager)用 esbuild 打包:
- 产物:每个页面打包为
<owner>-<localName>-<contentHash>.js,写入项目的.zhin/client/,通过GET /assets/client/*提供(cache-control: immutable,内容变了文件名就变)。@zhin.js/console-contract在打包时被替换成空实现,meta通过静态分析提取。 - React 只有一份:浏览器不认识
import 'react'这样的裸导入,所以白名单ALLOWED_ESM_CANONICAL(react、react-dom、react-dom/client、react/jsx-runtime(-dev)、react-router、react-router-dom)中的导入会被改写成/esm/<enc>.mjs,由 Host 按需构建和代理,保证整个 Console 只有一份 React。白名单之外的包返回 403。 - 路由:在
basic/cli/src/plugin-runtime/console/host.ts中注册。GET /console是页面索引,GET /console/api/pages返回页面清单,其余路径按地址匹配页面并返回页面外壳(包含 importmap 和挂载脚本);未匹配返回 404,权限不足返回 403。浏览器用import(moduleUrl)加载页面模块,把默认导出挂载到#root。