UI Plugin
UI Plugin 是极简模式(web-minimal)的界面插件。宿主是纯静态页面,插件以
iframe 挂载、按 slot.area 入驻不同区域,通过
postMessage 的 callAction 动作总线与宿主通信。
A UI plugin is an interface plugin for minimal mode. The host is a static page, plugins mount as
an iframe, land in an area declared by slot.area, and talk to the host
over the callAction postMessage bus.
目录结构Layout
ui-plugins/<id>/ # 市场仓库中的单元
├── plugin.json # 清单(必需)
├── index.html # entry 入口页面(必需)
└── ... # 样式/脚本/图片等相对引用资源(可选)
文件夹名 = plugin.json 的 id = 插件标识,三者必须一致。
Folder name, plugin.json's id, and the plugin identity must all be the same string.
plugin.json 字段
plugin.json fields
{
"id": "doodle-chat",
"name": "涂鸦聊天",
"description": "English one-liner.",
"description-en": "English one-liner.",
"icon": "🖍️",
"slot": { "area": "panel" },
"entry": "index.html",
"image": "cover.png",
"root": "user",
"builtin": false,
"dependency": [],
"configSchema": [
{ "key": "color", "type": "select", "label": "主题色",
"options": [{ "value": "warm", "label": "暖" }, { "value": "cool", "label": "冷" }],
"default": "warm" }
],
"backend": { "kind": "npm" }
}
| 字段Field | 必填Required | 说明Notes |
|---|---|---|
id |
是yes | 插件标识,与文件夹名一致 Plugin identity, identical to the folder name |
name |
是yes | 展示名Display name |
description / description-en |
是yes | 中/英文一句话描述One-line description in each language |
icon |
是yes | 图标(emoji 或图片路径)Icon, as an emoji or an image path |
slot |
是yes |
入驻区域,见下表。旧写法 slotDef: { "type": "panel", "area": "panel" } 亦可。
The area to mount into (table below). The older
slotDef: { "type": "panel", "area": "panel" } form still works.
|
entry |
是yes | 入口 html,默认 index.htmlEntry HTML, defaults to index.html |
root |
否no |
来源标记 user / sandbox / builtin,同 id 冲突时优先级为 用户 > 沙盒 > 内置。
Origin marker: user, sandbox, or builtin. On id
collisions, user beats sandbox beats builtin.
|
dependency |
否no | 依赖数组,见上文Dependency array, see above |
configSchema |
否no |
声明式配置表单,type 取 text / switch /
number / select / range / wallpapers。
Declarative config form. type is text, switch,
number, select, range, or wallpapers.
|
backend |
否no |
后端形态;{ "kind": "npm" } 表示插件自带 server/server.js(Node 后端)。
Backend shape. { "kind": "npm" } means the plugin ships
server/server.js.
|
slot.area 区域清单
slot.area values
area 是插件声明的唯一入驻维度;行为(单例 / 容器抽屉 / 浮窗)由宿主按区域内置,插件不要自行声明窗口类型。
area is the only thing a plugin declares. The host decides the behaviour —
singleton, container drawer, or floating window — so plugins should never declare a window type
themselves.
area |
名称Name | 宿主内置行为Host behaviour |
|---|---|---|
rail |
左侧白条Left rail | 图标栏本身,含仅图标 / 图标+文字两态The icon rail itself, with icon-only and icon+label states |
sidebar |
会话列表Session list | 同区域单例Singleton per area |
session |
会话Session | 同区域单例Singleton per area |
background |
背景Background | 同区域单例Singleton per area |
panel-container |
面板容器Panel container | 全局唯一容器,右上角抽屉切换视图One global container; the drawer at its top-right switches views |
panel |
面板Panel | 与聊天区并排的内容插件Content plugin sitting beside the chat |
pet |
浮窗Floating window | 多实例,可拖拽 / 缩放 / 隐藏Multiple instances, draggable, resizable, hideable |
popup |
模态弹窗Modal | 居中模态弹窗Centred modal |
callAction 动作总线
The callAction bus
插件调用宿主时发这条消息,id 用于匹配响应:
A plugin calls the host with this message; id matches the reply:
window.parent.postMessage({ type: 'callAction', id: 'req-1',
actionId: 'config.get', payload: { plugin_id: 'doodle-chat' } }, '*');
| 宿主 → 插件Host → plugin | 结构Shape |
|---|---|
| 调用结果Call result | { type: 'callActionResult', id, ok, data | error } |
| 配置下发Config push | { type: 'configApply', config } |
| 事件广播Event broadcast | { type: 'event', event, data } |
actionId 契约(payload → data)
actionId contracts (payload → data)
应答信封 { type: 'callActionResult', id, ok, data | error };ok 为 false 时读 error,未知 actionId 返回 null。实现见 ui/web-minimal/assets/host.js 的 dispatch()。
文件与上传:宿主 / 前端 / 后端同机,dialog.files 返回绝对路径,后端可直接 fs 读取(不必 base64 走 plugin.rpc);要交给 Agent(大模型)读用 POST /agent/upload(落盘 agent_vm/.buffer/upload)。
Reply envelope: { type: 'callActionResult', id, ok, data | error }; read error when ok is false; an unknown actionId returns null. Implementation: dispatch() in ui/web-minimal/assets/host.js.
Files & upload: host / frontend / backend share one machine, so dialog.files returns absolute paths the backend can read with fs (no base64 over plugin.rpc); to hand a file to the Agent (LLM), use POST /agent/upload (lands in agent_vm/.buffer/upload).
actionId |
payload | 返回(data)returns (data) |
|---|---|---|
| 会话 / 对话Session & chat | ||
state.get |
— |
宿主群状态副本 {activeSessionId, sessions, draft, draftParadigm, activeBranchId, ...}(其中 plugins 为 undefined)
Host group-state snapshot {activeSessionId, sessions, draft, draftParadigm, activeBranchId, ...} (with plugins undefined)
|
chat.send |
{session_id?, message} |
后端 /api/chat 的 JSON body(含 processed_message)
JSON body of /api/chat (includes processed_message)
|
chat.sendBatch |
{session_id?, events: [...]} |
后端 /api/chat/batch 的 JSON body(含 processed_events)
JSON body of /api/chat/batch (includes processed_events)
|
chat.interrupt |
{session_id?} |
后端 /api/chat/interrupt 的 JSON body
JSON body of /api/chat/interrupt
|
chat.traceToSkill |
{session_id?, name, goal, is_upgrade?} |
{status:"ok"}
(name / goal 为空会抛错)
(throws when name / goal is empty)
|
sessions.list |
— | 会话数组Array of sessions |
sessions.new |
— |
{status:"ok", draft:true}
;Agent 忙碌时 {status:"busy"}(不新建)
; {status:"busy"} while the Agent is busy (no new session)
|
session.switch |
{session_id} |
{status:"ok"}
;忙碌且切到别的会话时 {status:"busy"}
; {status:"busy"} when busy and switching away
|
session.rename |
{session_id, alias} |
{status:"ok", alias} |
session.delete |
{session_id} |
{status:"ok"} |
session.branch |
{session_id?, alias?} |
后端返回的会话对象(含 id);忙碌时 {status:"busy"}
The session object returned by the backend (with id); {status:"busy"} while busy
|
session.setDraftParadigm |
{paradigm} |
{status:"ok", paradigm}
;非草稿态 {status:"noop"}
; {status:"noop"} when not a draft
|
branch.select |
{branch_id?} (默认 main)(defaults to main) |
{status:"ok"} |
requests.list |
— | 待审批请求数组Array of pending requests |
requests.resolve |
{request_id, approved, feedback?, ignore?, duration?} |
后端请求 JSONBackend request JSON |
| 配置 / 设置Config & settings | ||
config.get |
{plugin_id} |
该插件配置对象(无则 {})
That plugin's config object ({} if none)
|
config.set |
{plugin_id, config} |
合并后的配置对象The merged config object |
settings.meta |
— | {plugins, config} |
settings.open / settings.close |
— | {status:"ok"} |
| 文件 / 数据Files & data | ||
dialog.files |
— |
绝对路径字符串数组;取消 → [](Electron 外调用抛错)
Array of absolute path strings; [] on cancel (throws outside Electron)
|
dialog.folder |
— | 同上(文件夹的绝对路径数组) Same as above (absolute folder paths) |
dialog.resolveFiles |
{files: [File]}
(<input type=file> 选好的 File)
(Files picked via <input type=file>)
|
{paths: [...]} |
dialog.pickWallpaper |
— |
上传后的后端 JSON;取消 → null
Backend JSON after upload; null on cancel
|
ref.graphs |
— | 名称字符串数组Array of name strings |
| 容器(float 插件用;宿主只操作调用方自己的实例)Container (for float plugins; the host only touches the caller's own instance) | ||
slot.move |
{dx?, dy?} |
{status:"ok"} |
slot.close |
— |
{status:"ok"}
(隐藏调用方浮窗)
(hides the caller's floating window)
|
slot.hide |
— |
{status:"ok"}
(「关闭并记住」,白条可再启用)
("close and remember"; re-enable from the rail)
|
| 插件自身Plugin itself | ||
plugin.delete |
{plugin_id} |
{status:"ok"} |
plugin.rpc |
{plugin_id, handler, payload?, timeout?} |
handler 的 result 本身(不是 {result} 包装)
The handler's result itself (no {result} wrapper)
|
| 内置浏览器(原生视图,仅 Electron 主窗口可用)Built-in browser (native view; Electron main window only) | ||
browser.available |
— | {available: boolean} |
browser.newTab |
{url} |
{tabId} |
browser.closeTab |
{tabId, nextTabId?} |
{status:"ok"} |
browser.switchTab |
{tabId} |
{status:"ok"} |
browser.navigate |
{tabId, url} |
{status:"ok"} |
browser.reload / browser.goBack / browser.goForward |
{tabId} |
{status:"ok"} |
browser.bounds |
{x, y, w, h}
(相对 iframe 的 CSS 像素)
(CSS pixels relative to the iframe)
|
{status:"ok"} |
browser.hide |
— | {status:"ok"} |
browser.pickStart |
{tabId} |
{imageDataUrl, width, height, viewportCssWidth, viewportCssHeight} |
browser.pickEnd |
{tabId} |
{status:"ok"} |
browser.locate |
{tabId, x, y} |
元素定位结果;不可用 → null
Element hit result; null if unavailable
|
browser.pickElement |
{tabId, x, y} |
元素语义信息(outerHTML / innerText / attributes / cssSelector / rect);不可用 → null
Element semantics (outerHTML / innerText / attributes / cssSelector / rect); null if unavailable
|
browser.openExternal |
{url} |
{status:"ok"}
(系统浏览器打开)
(opens in the system browser)
|
browser.openUrl |
{url} |
统一链接路由结果(http → 内置浏览器) Unified link routing result (http → built-in browser) |
事件(节选)Events (excerpt)
event |
时机When | 范围Scope |
|---|---|---|
host.unmount |
宿主卸载插件The host unmounts the plugin | 全体all |
host.nativeCover |
原生界面覆盖插件显示区域Native UI covers the plugin's area | 全体all |
session.switched / branch.switched |
会话或对话分支切换The session or branch switched | 全体all |
requests.updated |
待办请求状态变化A pending request changed state | 全体all |
browser.tabEvent |
内置浏览器标签页事件Built-in browser tab event | 全体all |
layout.empty |
布局空置(无会话等)The layout is empty | session |
config.updated |
插件配置被更新The plugin's config was updated | settings |
conversation.local |
本会话消息流本地即时插入Local immediate insert into the stream | session / sidebar |
conversation.updated |
本会话消息流发生变化The session's message stream changed | session / sidebar |
事件按 slot 路由,只发给声明了对应槽的插件。所以 panel 槽的插件收不到 conversation.*,而且它只带 session_id(无消息内容、无"完成"标记)——要判定 Agent 任务完成得自行轮询(产物 mtime / state.get)+ 超时兜底。
Events are routed by slot and only reach plugins in that slot. So a plugin in the panel slot never receives conversation.*, which only carries session_id anyway (no message content, no "done" flag) — to detect that an Agent task finished, poll instead (artifact mtime / state.get) with a timeout fallback.
开发注意Gotchas
-
宿主 CSS 变量不跨 iframe 继承,插件样式必须自带 fallback,如
color: var(--wm-label, #16191d);token 全量见ui/web-minimal/assets/theme.css。 Host CSS variables do not cross the iframe — always ship a fallback, e.g.color: var(--wm-label, #16191d); the full token list is inui/web-minimal/assets/theme.css. -
宿主跑在 Electron 里,
window.prompt/alert/confirm不可用(prompt直接返回null);输入与二次确认请用插件自绘浮层。 The host runs in Electron, wherewindow.prompt/alert/confirmare unavailable (promptjust returnsnull); build your own overlay for input and confirmation.
最小可运行示例Minimal runnable example
plugin.json plugin.json
{
"id": "hello-panel",
"name": "Hello 面板",
"description": "A minimal panel plugin.",
"description-en": "A minimal panel plugin.",
"icon": "🐾",
"slot": { "area": "panel" },
"entry": "index.html",
"configSchema": [
{ "key": "greeting", "type": "text", "label": "问候语", "default": "喵~" }
]
}
index.html index.html
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Hello</title>
<style>body{font-family:system-ui;margin:16px;background:transparent}</style></head>
<body>
<h3 id="hi">喵~</h3>
<button id="send">发到会话</button>
<script>
let reqSeq = 0;
const pending = new Map();
function call(actionId, payload) {
const id = 'r' + (++reqSeq);
return new Promise(res => { pending.set(id, res);
window.parent.postMessage({ type: 'callAction', id, actionId, payload }, '*'); });
}
window.addEventListener('message', e => {
const m = e.data || {};
if (m.type === 'callActionResult' && pending.has(m.id)) { pending.get(m.id)(m.ok ? m.data : m.error); pending.delete(m.id); }
if (m.type === 'configApply') { document.getElementById('hi').textContent = m.config.greeting || '喵~'; }
});
document.getElementById('send').onclick = () => call('chat.send', { message: '你好,来自面板插件' });
</script>
</body>
</html>
开发与调试Build & debug
-
本地预览:插件目录放到
~/.purrcat/webmin-plugins/<id>/,重启极简宿主(/minimal页面Ctrl+R)即可加载。 Local preview: drop the folder into~/.purrcat/webmin-plugins/<id>/and reload the host page at/minimalwithCtrl+R. -
调试:插件跑在 iframe 里,用开发者工具选中对应 iframe 上下文看 Console;
callAction出错会落在宿主控制台。 Debugging: the plugin runs inside an iframe — pick that iframe's context in DevTools to read its console.callActionerrors surface in the host console. -
自带后端:声明
backend: { "kind": "npm" }并提供server/server.js(Node;依赖放插件自己的package.json/node_modules),插件侧用plugin.rpc调用;宿主把它当独立子进程拉起,进程退出后下次 RPC 会自动重新拉起。
协议走 stdio 单行 JSON:宿主发{"id","handler","payload"},插件回{"id","ok":true,"result":...}或{"id","ok":false,"error":"..."}; 启动后必须向 stdout 打一行{"event":"ready"},否则宿主 15 秒后判定"后端未就绪"; stdout 只许输出协议行。
调试:后端stderr会被宿主捕获并在报错里回显;iframe 的console在宿主 Electron 窗口的开发者工具(Ctrl+Shift+I)里看。 Own backend: declarebackend: { "kind": "npm" }, shipserver/server.js(Node; dependencies go in the plugin's ownpackage.json/node_modules), and call it from the plugin withplugin.rpc; the host runs it as a separate child process and auto-restarts it on the next RPC after it exits.
The protocol is stdio, one JSON line at a time: the host sends{"id","handler","payload"}and the plugin replies{"id","ok":true,"result":...}or{"id","ok":false,"error":"..."}; on startup it must write one line{"event":"ready"}to stdout, or the host reports "backend not ready" after 15 s; only protocol lines may go to stdout.
Debugging: backendstderris captured by the host and surfaced in error messages; the iframe'sconsoleis in the host Electron window's DevTools (Ctrl+Shift+I). -
完整字段见仓库
docs/plugins/ui-plugin.md,宿主接入见docs/api/webmin.md。 Full details:docs/plugins/ui-plugin.mdanddocs/api/webmin.md.
最小骨架(照此改即可,别漏掉最后那行 ready): Minimal skeleton (adapt this, don't drop the final ready line):
'use strict';
const readline = require('readline');
const handlers = {
ping: (payload) => ({ pong: (payload && payload.msg) || null }),
};
function send(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', (line) => {
const text = String(line || '').trim();
if (!text) return;
let req;
try { req = JSON.parse(text); }
catch (_) { send({ id: null, ok: false, error: '请求不是合法 JSON' }); return; }
const fn = handlers[req.handler];
if (!fn) { send({ id: req.id, ok: false, error: '未知 handler: ' + req.handler }); return; }
try { send({ id: req.id, ok: true, result: fn(req.payload || {}) }); }
catch (e) { send({ id: req.id, ok: false, error: String((e && e.message) || e) }); }
});
// ★ 必须:就绪握手,缺了它宿主会等 15 秒后判定"后端未就绪"
send({ event: 'ready' });