Files
inkreach-agent/docs/plugins.md
T

13 KiB
Raw Blame History

插件系统(harness 插件契约)

教程入口:docs/plugins/README.md —— L0 第一个插件 → L1 工具命令 → L2 设置 → L3 UI 槽位 → L4 React 组件 → L5 自诊断;视觉规范 DESIGN.md;脚手架 packages/plugin-template。本文件是契约全集(参考手册)。

状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap) 契约版本:HARNESS_CONTRACT_VERSION = 0.2.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": "Globe",               // 侧栏/面板/胶囊图标:lucide 图标名(白名单见 DESIGN.md;kebab 亦可);兼容旧 svg 字符串;未知名回落拼图图标
  "order": 10,                   // 侧栏排序(只在插件间;核心入口恒在前)
  "panel": true,                 // 在右侧 SidePanel 出现一个 tab
  "entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" },   // 内置插件省略 entry
  "replacesNative": false,
  "updateURL": "https://…/index.json"
}

外置插件源(EXTERNAL_PLUGINS_ENABLED 开发版开/用户版关,ASO_EXTERNAL_PLUGINS=1 豁免):userData/plugins/<id>/ > 项目 .pi/extensions/<id>/ > 全局 ~/.pi/agent/extensions/<id>/。渲染层模块文件不要命名为 index.ts/index.js——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载。

主进程插件契约(@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 编译

渲染层模块契约(@aso/plugin-sdk/renderer)

外置插件以 React 组件形态进入宿主界面(与内置同槽位),不再是 iframe 物理隔离:

// manifest.json
{ "id": "demo-ui", "panel": true, "entry": { "renderer": "renderer.mjs" } }
// renderer.mjs —— 宿主渲染层动态 import(plugin://<id>/renderer.mjs) 后调用默认导出
export default function mount(ctx) {
  ctx.registerView(MyViewComponent)   // 整页视图(侧栏入口 → 主内容区)
  ctx.registerPanel(MyPanelComponent) // 面板 tab 内容(需 panel: true)
}
  • 单实例约束:组件必须用 ctx.react(宿主 React,即 window.__ASO_PLUGIN_HOST__.react)创建;插件自带 React 副本会导致 hooks 全部失效
  • JSX 工程构建配方(esbuild,react 别名到宿主实例的 shim):
esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
  --jsx=automatic \
  --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
// shims/react.js —— 构建期别名目标
const R = window.__ASO_PLUGIN_HOST__.react
export default R
export const { useState, useEffect, useRef, useMemo, useCallback, createContext,
  useContext, Fragment, memo, forwardRef, createElement } = R
// shims/jsx-runtime.js
const J = window.__ASO_PLUGIN_HOST__.jsxRuntime
export const { jsx, jsxs, Fragment } = J
  • 装载时机与热重载:插件列表变化(扫描/启停/plugins:reload)即按 ?t= cache-bust 重装;被禁用/移除的插件注册随之清除
  • 门控:仅开发版装载(import.meta.env.DEV + 主进程 EXTERNAL_PLUGINS_ENABLED)
  • 性能护栏:读源与 import 各限时 8s,超时/失败由宿主槽位呈现统一等待/失败态(ErrorState + 重试,见 plugins/renderer-host);渲染期崩溃由 PluginErrorBoundary 兜底

公共 UI 组件库与异步工具(@aso/plugin-sdk/ui · withTimeout/withRetry)

  • ./ui 子入口:宿主视觉规范的代码化——Button/Input/Select/Switch/Card/SettingRow/Badge
    • 三态组件 LoadingState/ErrorState/EmptyState/Spinner。仅 renderer 组件槽位可用 (渲染在宿主 DOM,复用宿主样式表);组件目录与三态铁律见 docs/plugins/07-ui-components.md
  • 异步工具(主入口导出):withTimeout(promise, ms, label?)、withRetry(fn, { retries, delayMs, timeoutMs, onRetry })、 TimeoutError——插件内一切可能挂起的异步(网络/子进程/文件轮询)必须自带超时与重试
  • 宿主兜底:插件激活限时 10s(异步工厂超时/reject 即标记插件失败,不拖垮 runtime 重建); renderer 装载限时 8s
  • 机器关卡:pnpm plugin:check <插件目录> 校验契约完整性、同步 IO、console.*、 React 单实例、hex 色值、三态使用等(error 级不通过即 exit 1),规则见 docs/plugins/08-performance.md

插件设置(声明式 schema,api.ui.registerSettings)

插件在 main.mjs 里声明配置表单,宿主负责渲染/持久化/回推——schema 是纯数据(不可含函数/React 元素):

// main.mjs
api.ui.registerSettings({
  title: '我的插件设置',
  items: [
    { type: 'string', key: 'token', label: 'API Token', password: true },
    { type: 'boolean', key: 'enabled', label: '启用增强', default: true },
    { type: 'select', key: 'mode', label: '模式', options: [{ value: 'fast', label: '快速' }], default: 'fast' },
    { type: 'path', key: 'dir', label: '数据目录', mode: 'directory' },
    { type: 'image', key: 'logo', label: 'Logo' },
    { type: 'button', key: 'test', label: '测试连接', variant: 'primary' }
  ]
})

api.ui.getSettingValues()            // 当前值(schema default 已合并)
api.ui.onSettingsChange((v) => {})   // 用户修改设置后实时推送
api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮项 key)
  • 标准控件(PluginSettingItem,按 type 判别):string(含 password)/ text 多行 / number / boolean(switch)/ select / multiSelect / slider / color / path(file|directory)/ image(选择+缩略图)/ button(动作)/ info / divider
  • 持久化:值存 userData/aso-plugin-values.json(按插件隔离);getSettingValues 合并 schema 默认值
  • 一级入口:有配置项(items 非空)的插件,在插件页行内出现「一级入口」开关;打开后设置页左侧「Agent 能力」组追加以插件名命名的导航项,点击进二级配置页。items 为空/未声明 → 无开关、无导航项
  • 值变更经 plugins:settingsValues 广播:设置页与插件主进程两侧同步

UI 槽位总览(对位 pi 的 ctx.ui)

槽位 机制 状态
侧栏入口(B1) manifest icon/name/order;点进插件整页视图 ✅
插件整页 Main 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 ✅
面板 tab(B6) panel: true;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) ✅
状态胶囊(B2) setStatusItem 数据驱动,TitleBar 渲染,两种视图均可见;顶栏为紧凑状态条,点击可展开宿主渲染的详情弹层(detail:分组/行/徽标/行内深链) ✅
深链 openView(view, params)(main 侧 IPC 广播 / 页侧桥直通) ✅
工具栏按钮(B5) registerToolbarButton 数据已通,Composer 工具行渲染 ⏳ 下版本
聊天流富组件(B4) 类型 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) ⏳ 下版本
聊天区浮动胶囊(B3) 独立浮动态暂缓——顶栏胶囊的详情面板(ZCode 式)已覆盖同场景;数据同源(setStatusItem) ⏳

交互基线

  • 整页替换:点插件入口 → 主列(聊天)隐藏保活;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 plugin:(renderer 模块动态 import);.mjs/.js 强制 JS MIME
  • 外置装载为 build 级开关(EXTERNAL_PLUGINS_ENABLED:开发版开/用户版关,ASO_EXTERNAL_PLUGINS=1 豁免);外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——签名校验前,仅自行放置的插件目录可装载(M5 后强制)
  • 外置 renderer 模块以渲染层特权运行(无 iframe 沙箱):同样受上述 build 开关门控;组件崩溃不越出 ErrorBoundary

pi 原生目录(M7)

事实:pi resource-loader 默认自动加载 ~/.pi/agent/extensions/*.ts 与 <cwd>/.pi/extensions/* /index.ts(noExtensions 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。

已落地:harness 把同一批目录当作一等外置插件源——目录内含 harness manifest.json 的子目录获得完整插件待遇(main.mjs 工厂 / entry.renderer React 组件 / entry.page iframe / 面板 tab / 管理页开关)。pi 只自动发现 index.ts/index.js,harness 约定入口为 main.mjs/renderer.mjs,因此不会被 pi 双重加载;纯 pi 扩展(index.ts)仍由 pi 原生装载、不参与 harness UI。项目级目录随工作区切换同步(setProjectExtensionsDir)。未做:项目目录的 project-trust 门控(与 pi 侧对齐)、设置页按源展示。

开发工作流(外置插件,M6 模板落地后)

packages/plugin-template  →  npm i(workspace 引 @aso/plugin-sdk)
  页面:vite build --watch → dist/      # 改即重编
宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src