Language

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 in ui/web-minimal/assets/theme.css.
  • 宿主跑在 Electron 里,window.prompt / alert / confirm 不可用(prompt 直接返回 null);输入与二次确认请用插件自绘浮层。 The host runs in Electron, where window.prompt / alert / confirm are unavailable (prompt just returns null); 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 /minimal with Ctrl+R.
  • 调试:插件跑在 iframe 里,用开发者工具选中对应 iframe 上下文看 Console;callAction 出错会落在宿主控制台。 Debugging: the plugin runs inside an iframe — pick that iframe's context in DevTools to read its console. callAction errors 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: declare backend: { "kind": "npm" }, ship server/server.js (Node; dependencies go in the plugin's own package.json / node_modules), and call it from the plugin with plugin.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: backend stderr is captured by the host and surfaced in error messages; the iframe's console is 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.md and docs/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' });