webui 服务(Web 管理后台 / 前端托管)
1. 定位
WebUI 是 Aalis 的 Web 管理后台:启动一个 HTTP 服务器,提供 REST API(插件管理 / 配置 / 市场 / 日志 / 运行状况(需要处理、平台连接)/ 媒体与文件 / 声明式页面与布局记录)+ WebSocket(/session 接会话协议;/admin 推管理面事件:日志、插件与页面登记表的变化、页面数据变了、布局记录变了、「需要处理」变了、重启、换前端),并托管前端静态文件。第三方插件通过它登记页面与提示(页面默认声明式,需要自绘时用隔离的插件前端),也可整体替换前端或替换整个服务实现。
注意服务注册名(描述符 name)有两个,而非单个 'webui':
'webui-server'—— 后端服务,契约WebUIService。描述符webuiServer,绑定接口BoundWebui(packages/api-webui/src/index.ts)。'webui-client'—— 前端「忒修斯之船」provider,契约WebuiClientProvider。描述符webuiClient,契约不给 bind,业务层就是WebuiClientProvider形状的活句柄。
契约包:@aalis/api-webui(packages/api-webui/src/index.ts 与 page.ts、frame.ts、theme.ts、rest.ts),MIT。它导出运行时服务契约、页面与提示的类型、插件前端的消息通道(消息类型、校验函数与宿主数据能力)、管理面事件、管理面 REST 的路由表与请求和回应类型、主题格式,以及向 PluginMeta 注入的 extends。
参考实现 @aalis/plugin-webui-server 与前端 @aalis/plugin-webui-client 均为 AGPL-3.0-only(与契约包许可不同,见第 6 节 AGPL 说明)。
页面与提示不是静态模块字段:在 apply 里经 webui.use.definePage / webui.use.notify 登记,随本次激活撤回。契约的全文(组件、动作、会话面板、质量门槛、路由)见 api-webui。
2. 契约
后端服务 WebUIService(packages/api-webui/src/index.ts)
export interface WebUIService {
getPort(): number;
getHost(): string;
addPage(page: PageDefinition, instance: string): () => void;
invalidate(instance: string, pageKey: string, componentId?: string, sessionId?: string): void;
addNotice(notice: Notice, instance: string): () => void;
}绑定接口 BoundWebui
export interface BoundWebui extends WebUIService {
definePage(page: PageDefinition): PageHandle; // 泛型推断动作输入与引用,见 api-webui
notify(notice: Notice): NoticeHandle;
}definePage / notify 走 registrar:同键替换(页面按 key,提示按 id)、提供者换人整体重挂、关闭后拒收。插件不要自己传实例 id。definePage 返回的 invalidate 推给打开着这一页的界面;notify 返回的 clear 撤回这条提示。
前端 provider WebuiClientProvider
export interface WebuiClientProvider {
getClientDir(): string;
}声明式页面(packages/api-webui/src/page.ts)
一个页面 = key、标题、lucide 图标名、位置(插件详情、设置的一节、会话面板)、内容(组件数组或标签页)与动作。组件只有五样:table、graph、info、markdown、actions;表格与关系图可带详情。数据源与处理函数写在声明里,返回形状按组件种类推出;动作带 effect(read、write、destructive)、可选的 input(一组 schema-config 字段)与 row(行动作)。登记时 webui-server 按质量门槛校验,数据源与动作经服务端闸门调用。详见 api-webui。
声明式表达不了的页面用插件前端:definePage 写 frame(不写 content),插件包的 webui/ 里放一份静态网页,webui-server 在 /frames/ 下托管,宿主画进与宿主页面不同源的 iframe;它只能经消息通道调这一页声明过的数据源(sources)与动作,位置只有插件详情与会话面板。详见 api-webui。
配置表单用到的字段属性
本包不向 SchemaField 注入属性。secret(值是凭据)与影响取值判定的 dynamicOptions / allowCustom(parseConfig / validateConfig 据此决定是否查选项成员资格)都由 @aalis/schema-config 声明,插件用到它们不必引入本包。动作的 input 用同一套字段。secret 字段在 WebUI 里的呈现见第 5 节。
subsystem 写在 definePlugin({ subsystem })(PluginDefinition 字段,core 不读)。extends 经本包 declaration merging 挂到 PluginMeta。
3. 谁提供 / 谁消费
提供方
@aalis/plugin-webui-server—— 参考实现。provides: [webuiServer, platform](packages/plugin-webui-server/src/index.ts),provide.use(webuiServer, impl)。它同时以webui平台名登记一个platformadapter:平台名标明说话的是从 WebUI 来的 owner,连接状态数的是打开着的/session连接;WebUI 的会话不经它收发。sessionProtocol、approvals等为 optional。@aalis/plugin-webui-client—— 默认前端(React SPA)。不是被加载的插件,而是带aalis.client: true标记 +dist/index.html的纯静态包,被 webui-server 在app:ready时自动发现并登记成webui-client的一个 provider(entryId取前端包名,偏好认它;条目归 webui-server 这次激活)。
消费方(经 webui.use.definePage / notify):
| 插件 | 页面 |
|---|---|
plugin-scheduler | 计划任务 |
plugin-authority | 权限(设置) |
plugin-skills | 技能(设置) |
plugin-doctor | 诊断,另按每次运行的结果发出或撤回提示 |
plugin-workflow | 工作流 |
plugin-tool-browser | 浏览器 |
plugin-user-relation | 人物关系图(graph 组件,疏密取本插件的 graphSpacing)与原始数据 |
plugin-file-reader | 本会话上传的文件(会话面板) |
plugin-usage-stats | 用量统计(按天、按周、按模型,服务商实现了余额查询时另有余额) |
plugin-mcp-client | 没有页面;连不上的 MCP 服务器各发一条提示,改好配置重载后撤回 |
页面登记在 webui-server 实例的内存登记表里,路由按这张表分派。
4. 写一个 provider
绝大多数第三方作者不替换 webui-server,只是登记页面与提示。
4a. 消费 WebUI:登记一个声明式页面(最常见)
import { webuiServer } from '@aalis/api-webui';
import { definePlugin, optional } from '@aalis/core';
export default definePlugin({
name: '@acme/plugin-my-webui',
subsystem: 'tools',
uses: { webui: optional(webuiServer) },
apply({ webui }) {
const page = webui.use.definePage({
key: 'my-plugin',
title: '我的插件',
icon: 'wrench',
actions: { reset: { label: '清空', effect: 'destructive', confirm: '清空全部条目?', handler: async () => store.clear() } },
content: [
{ type: 'info', id: 'summary', data: () => [{ label: '条目数', value: store.size }] },
{ type: 'actions', actions: ['reset'] },
],
});
store.onChange(() => page.invalidate('summary'));
},
});webui-server 缺失时登记排队、就绪后挂上,页面不显示。不必把 webuiServer 标成 required。
业务句柄 webui.use 是活的(选择、缺席与固定同一家的规则见 选择层与业务层),definePage / notify 的账本随提供者换人整体重挂。经 services.use.get(webuiServer) 动态查到的是原始实例,不随换人重挂,不要长期持有。
4b. 替换前端(webui-client)
- 纯静态包:
package.json标aalis.client: true+ 提供dist/index.html,被 webui-server 自动发现挂载,无需apply。多前端共存时各成一个webui-clientprovider,活跃者由「服务偏好」在 WebUI 设置「系统」里切换;卡住可访问/__clients逃生页切回。 - 主动覆盖:插件
apply里provide.use(webuiClient, { getClientDir: () => myDir }, { label: '我的前端' }),展示名优先取提供方插件的displayName,没有时取provide.use的label选项。替别的包登记时entryId取被托管包的 id;条目仍归登记的激活清理,计入它的provides。
4c. 替换整个后端(webui-server)
罕见。实现全部 WebUIService 必须方法(addPage、addNotice 要真正维护登记表,invalidate 要推给界面),用更高的 priority 压过默认实现,或由用户在 WebUI 设置「系统」的服务偏好 / 配置 servicePreferences 选中它。definePlugin({ provides: [webuiServer], uses: { provide }, apply({ provide }) { provide.use(webuiServer, impl); } })。同名时选中哪一家的规则见 docs/concepts/service-model.md:本插件偏好 > 全局偏好 > priority > 注册顺序。
5. 标准消费方式
- 登记:始终经
BoundWebui.definePage/notify,不要绕过门面直接调WebUIService.addPage(..., instance)——否则提供者换人时不会自动重挂,激活撤回也不会按条目退订。 - 服务缺失:未就绪时
webui.present为false,访问查询类成员会抛错;登记方法不受影响,用门面即可。 - 错误边界:数据源或处理函数抛错,路由回
500 { error },界面在那一块或操作的人那里显示原因;处理函数成功时可以返回一句话,宿主用 Toast 显示。 - 动态选项:表单字段标
dynamicOptions: '<来源名>',前端经 webui-server 的/api/models/:service取:先找插件经贡献点webui:options交的同名来源;llm、toolGroups、llm-thinking由宿主回答;其余合起那个服务各提供者的listModels()(见 api-webui)。 - 密钥字段:
secret: true的字段,宿主提供密钥存储时GET /api/plugins回的是配置文档里的引用{ $secret: '<键>' },不回原值(没有密钥存储的自组装宿主,文档里本就是明文,接口原样返回)。表单对已设置的字段显示「已设置」和键名,不显示值;输入新值即替换,不动(或输入后删空、点撤销)则原样回传引用、保持原值,点清除则清空。顶层与分组里的字段,键被几处配置共用时提示替换会一并影响它们;数组元素里的字段替换时另建新键,不影响共用者,不提示。标在 map、list 上的字段整个值算一个密钥,只写不回显,要改就整份重填。存放与写入规则见docs/core/config.md的「密钥配置」一节。
6. 能力 / 风险 → 影响
鉴权:所有需登录 REST 路由 = owner-only
单 owner 终态下「持 token ⟺ webui:console ⟺ owner」。createRouteGate(packages/plugin-webui-server/src/gate.ts)只做身份解析:解析得到放行,否则 403。多账户 / 能力委托已剥离,没有 per-route 档位裁决。
- 数据源与处理函数只有 owner 能调用,由 webui-server 的闸门保证,收到的
ctx.caller即 owner 身份,处理函数不另核调用者;在线用户能进 WebUI 时,契约会另给按调用者身份裁剪的办法。write与destructive的动作每次调用 webui-server 记一行 info 日志。 - 工具的 owner 审批经
/session连接(会话协议的approval/*)送达与应答,webui-server 登记webui审批面;指令的意图确认是同一会话里的一问一答,不经 webui-server。鉴权双轴见docs/plugins/plugin-authority.md、docs/concepts/security-model.md。
SSRF:图片代理走 safeFetch
/api/proxy/image 用 @aalis/util-network-guard 的 safeFetch(packages/plugin-webui-server/src/routes/proxy.ts)。该函数逐跳 redirect:'manual' + 每跳重新校验协议与 host,只转发按文件头认出的图片(PNG、JPEG、GIF、WebP,与 /api/media 同一份格式),content-type 取判定结果、不看上游写的,另有 20MB 上限、15s 超时、x-content-type-options: nosniff + content-security-policy: sandbox。任何 egress 都应走 safeFetch,别直接 fetch 用户给的 URL。
跨会话隔离
会话的条目只推给订阅了这个会话的 /session 连接(见 api-session-protocol)。/admin 的事件不按会话分:日志推给订阅了的连接,其余推给全部连接;日志里可能带会话内容。
7. 注意事项与边界情形
- 页面表是 webui-server 实例内存态:webui-server 被替换或重载会清空页面与提示的登记表;这正是绑定门面用
registrar自动重挂的原因。owner 的布局记录(固定的页面、会话面板的标签)存在插件数据里,不随之丢失。
8. 交叉链接
- 概念:
docs/concepts/service-model.md、docs/concepts/lazy-service-access.md、docs/concepts/manifest-metadata.md、docs/concepts/security-model.md、docs/concepts/storage-uri-grammar.md。 - 核心:
docs/plugins/plugin-authority.md、docs/core/service.md、docs/core/context.md、docs/core/config.md、docs/core/plugin.md。 - 相关服务:
docs/services/platform.md、docs/services/gateway.md、docs/services/storage.md、docs/services/llm.md、docs/services/embedding.md。