6.8 KiB
6.8 KiB
插件系统(harness 插件契约)
状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap) 契约版本:
HARNESS_CONTRACT_VERSION = 0.1.0(src/main/plugins/plugin-host.ts;manifest.minContract 与之比对,不满足拒载)
两层能力模型
| 内置插件 | 外置插件 | |
|---|---|---|
| 位置 | 代码注册(src/main/plugins/builtin/index.ts),UI 组件编译进宿主 bundle |
userData/plugins/<id>/(manifest.json + 页面资产 + main.mjs) |
| UI 形态 | 宿主树 React 组件(renderer/src/plugins/builtin-components.ts,key=插件 id) |
iframe 页 plugin://<id>/<entry.page>(自带 React,物理隔离) |
| main 侧 | 第一方工厂(不动态 import) | 动态 import()(cache-bust 重载) |
| 宿主能力 | 全量(BrowserSlot/原生视图协调/window.api) | 仅 SDK 桥接 API(postMessage,白名单 invoke) |
| 信任 | 随包分发,免签名 | 签名校验(M5 接入;当前未签名也可装载——TODO) |
| 管理 | 同一份 manifest/生命周期/管理页(可禁用) | 同左 + zip 导入/在线更新(M4/M5) |
同 id 冲突:高 semver 胜、平级外置胜;外置遮蔽"组件级"内置必须声明 replacesNative: true(能力降级声明,防原生视图能力被页面级外置静默顶掉)。
manifest.json
{
"id": "hotwords", // ^[a-z][a-z0-9-]*$
"name": "热点词",
"version": "1.2.0", // semver(x.y.z)
"minContract": "0.1.0",
"description": "…",
"icon": "<svg…/>", // 侧栏/面板图标(svg 字符串;宿主清除事件属性与 script)
"order": 10, // 侧栏排序(只在插件间;核心入口恒在前)
"panel": true, // 在右侧 SidePanel 出现一个 tab
"entry": { "page": "index.html", "main": "main.mjs" }, // 内置插件省略 entry
"replacesNative": false,
"updateURL": "https://…/index.json"
}
主进程插件契约(@aso/plugin-sdk 主入口)
import { definePlugin } from '@aso/plugin-sdk'
export default definePlugin((api) => {
// pi 原生扩展 API:工具/事件/命令/消息(语义与 pi CLI 扩展一致)
api.pi.registerTool(myTool)
api.pi.on('agent_end', () => { /* … */ })
// harness UI 槽位(数据驱动)
api.ui.setStatusItem('task', { text: '任务 3/5', tone: 'accent', openView: { view: 'hotwords', params: { id: 1 } } })
api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } })
api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页
// 插件私有 KV(userData/aso-plugin-storage.json,按 pluginId 隔离)
api.storage.set('lastQuery', 'xxx')
return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ }
})
实现:harness 在 pi extensionFactories 挂一个聚合 extension,工厂内创建真实 ExtensionAPI 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。
插件页(iframe)契约(@aso/plugin-sdk/client)
import { getHarnessClient, useHarnessTheme, useDeepLinkParams } from '@aso/plugin-sdk/client'
const harness = getHarnessClient('hotwords')
harness.openView('chat') // 深链返回聊天/其他插件
harness.setStatus('task', { text: '进行中' }) // 状态胶囊(写主进程单一事实源)
harness.invoke('getState') // 白名单:getState / send / getSettings
useDeepLinkParams('hotwords') // openView 携带的参数(含挂起重放)
useHarnessTheme('hotwords') // { dark, accent }(深浅色/accent 变化推送)
- 通信:postMessage(页 →
window.parent;宿主 → iframe.contentWindow) - 安全:宿主校验
event.source === iframe.contentWindow且event.origin === 'plugin://<id>';sandbox 无allow-top-navigation;iframe 页无 window.api 直达 - 主题连续性:插件页
<link rel="stylesheet" href="peer://assets/tokens.css">(宿主 beautifului.css 的 token 表,深浅色由.dark类 + 下发的 accent 生效);建议插件自带 Tailwind 按同 token 编译
UI 槽位总览(对位 pi 的 ctx.ui)
| 槽位 | 机制 | 状态 |
|---|---|---|
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
| 插件整页 Main | 内置组件级 / 外置 iframe;聊天列 display:none 保活 | ✅ |
| 面板 tab(B6) | panel: true;SidePanel 零改动承载(内置组件或 iframe) |
✅ |
| 状态胶囊(B2) | setStatusItem 数据驱动,TitleBar 渲染,两种视图均可见 |
✅ |
| 深链 | openView(view, params)(main 侧 IPC 广播 / 页侧桥直通) |
✅ |
| 工具栏按钮(B5) | registerToolbarButton 数据已通,Composer 工具行渲染 |
⏳ 下版本 |
| 聊天流富组件(B4) | 类型 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) |
⏳ 下版本 |
| 聊天区浮动胶囊(B3) | 与 B2 同源数据,折叠/展开双态 | ⏳ 下版本 |
交互基线
- 整页替换:点插件入口 → 主列(聊天)隐藏保活;TitleBar 常驻(显示插件名 + 窗口控制 + 胶囊)
- 返回聊天:点会话/新会话/Ctrl+N(全局);Esc 只关弹窗、不切视图
- Ctrl+K / Ctrl+, 仅聊天视图;切回聊天自动重新吸底(ChatView
visible) - 侧栏"会话"区头徽标:插件视图期间提示代理活动(待审批 > 出错 > 流式中)
- 重载:主进程
plugins:reload(重扫 manifest + 重导入 main.mjs + 清内联 UI + 重建 runtime);渲染层收到列表变化后 iframe 换 src 整页重载(= pi/reload等效)
安全清单
plugin://特权协议:路径解析后必须落在该插件目录内(防穿越);peer://仅暴露白名单资产(tokens.css)- CSP:宿主页只加
frame-src plugin:(iframe 方案不动 script-src) - 外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——签名校验前,仅自行放置的插件目录可装载(M5 后强制)
pi 原生目录(M7,未实现)
事实:pi resource-loader 默认自动加载 ~/.pi/agent/extensions/*.ts 与 <cwd>/.pi/extensions/*.ts(noExtensions 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。M7 计划:设置开关(默认关 → noExtensions)+ 管理页可见/单禁 + 参与统一重载。
开发工作流(外置插件,M6 模板落地后)
packages/plugin-template → npm i(workspace 引 @aso/plugin-sdk)
页面:vite build --watch → dist/ # 改即重编
宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src