feat: 动态插件系统 M0-M3(两层能力模型 + pi 原生契约 + Obsidian 式 SDK)

This commit is contained in:
Rick Ye
2026-09-10 02:15:55 +08:00
parent 2b69a9aa13
commit 367f3f5c5b
37 changed files with 2009 additions and 5773 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@aso/plugin-sdk",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Inkreach harness 插件契约:清单/主进程工厂类型 + definePlugin + 插件页桥接 client(主入口给 main.mjs 作者,./client 给 iframe 页作者)",
"exports": {
".": "./src/index.ts",
"./client": "./src/page.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.0"
},
"peerDependencies": {
"react": ">=19"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"devDependencies": {
"@types/react": "19.2.18",
"react": "19.2.8",
"typescript": "7.0.2"
}
}
+158
View File
@@ -0,0 +1,158 @@
/**
* 插件页(iframe)侧桥接 client。
* 用法(插件页入口):`import { getHarnessClient } from '@aso/plugin-sdk/client'`
* 通信:postMessage —— 页 → window.parent;宿主 → iframe.contentWindow。
* 宿主严格校验消息来源 origin(plugin://<id>);invoke 仅暴露白名单方法。
*/
import type { OpenViewAction, StatusItem } from './status'
/** 桥协议版本(宿主与插件页握手比对;不匹配时宿主拒绝 invoke) */
export const HARNESS_BRIDGE_VERSION = 1
/** 宿主 → 插件页 */
export type HostToPluginMessage =
| { v: number; type: 'harness:hello'; contract: string }
| { v: number; type: 'harness:theme'; dark: boolean; accent?: string }
| { v: number; type: 'harness:deep-link'; params?: unknown }
| { v: number; type: 'harness:agent'; event: HarnessAgentEvent }
| { v: number; type: 'harness:invoke-result'; seq: number; ok: true; value: unknown }
| { v: number; type: 'harness:invoke-result'; seq: number; ok: false; error: string }
/** 插件页 → 宿主 */
export type PluginToHostMessage =
| { v: number; type: 'plugin:hello'; pluginId: string }
| { v: number; type: 'plugin:open-view'; view: string; params?: unknown }
| { v: number; type: 'plugin:set-status'; id: string; item: StatusItem | null }
| { v: number; type: 'plugin:invoke'; seq: number; method: string; args: unknown[] }
/** 宿主中继的 agent 事件(宽松类型:具体事件形状由宿主版本决定,插件页防御性读取) */
export interface HarnessAgentEvent {
type: string
[key: string]: unknown
}
/** invoke 白名单(宿主桥逐方法放行;后续按需扩充) */
export interface HarnessInvokeMap {
getState(): unknown
send(text: string, images?: { mimeType: string; data: string }[]): void
getSettings(): unknown
}
export interface HarnessTheme {
dark: boolean
accent?: string
}
export interface HarnessClient {
/** 握手完成(收到 harness:hello)后 resolve */
ready: Promise<void>
/** 宿主契约版本(ready 后可用) */
contractVersion(): string
/** 深链打开视图 */
openView(view: string, params?: unknown): void
/** 写/清一条状态胶囊(item=null 移除) */
setStatus(id: string, item: StatusItem | null): void
/** 深链参数:页面被 openView 打开/更新时回调(含挂起期间的一次重放) */
onDeepLink(cb: (params: unknown) => void): () => void
/** 主题变化(深浅色/accent) */
onTheme(cb: (theme: HarnessTheme) => void): () => void
/** agent 事件中继(宿主挑选的子集:state/agent_start/agent_end/stats…) */
onAgentEvent(cb: (event: HarnessAgentEvent) => void): () => void
/** 调用宿主白名单 API */
invoke<M extends keyof HarnessInvokeMap>(
method: M,
...args: Parameters<HarnessInvokeMap[M]>
): Promise<Awaited<ReturnType<HarnessInvokeMap[M]>>>
}
type DeepLinkCb = (params: unknown) => void
type ThemeCb = (theme: HarnessTheme) => void
type AgentCb = (event: HarnessAgentEvent) => void
let cached: HarnessClient | null = null
/** 获取桥接 client(单例;首个调用即开始握手) */
export function getHarnessClient(pluginId: string): HarnessClient {
if (cached) return cached
let contract = ''
let resolveReady: (() => void) | null = null
const ready = new Promise<void>((resolve) => {
resolveReady = resolve
})
const deepLinkCbs = new Set<DeepLinkCb>()
const themeCbs = new Set<ThemeCb>()
const agentCbs = new Set<AgentCb>()
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
let seq = 0
window.addEventListener('message', (event: MessageEvent) => {
const msg = event.data as HostToPluginMessage | undefined
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string' || !msg.type.startsWith('harness:')) return
switch (msg.type) {
case 'harness:hello':
contract = msg.contract
resolveReady?.()
resolveReady = null
break
case 'harness:theme':
for (const cb of themeCbs) cb({ dark: msg.dark, accent: msg.accent })
break
case 'harness:deep-link':
for (const cb of deepLinkCbs) cb(msg.params)
break
case 'harness:agent':
for (const cb of agentCbs) cb(msg.event)
break
case 'harness:invoke-result': {
const waiter = pending.get(msg.seq)
if (!waiter) break
pending.delete(msg.seq)
if (msg.ok) waiter.resolve(msg.value)
else waiter.reject(new Error(msg.error))
break
}
default:
break
}
})
const post = (msg: PluginToHostMessage): void => {
// 宿主 origin 随运行模式不同(file:// 或 dev server),用 '*' 发送;
// 方向安全由宿主侧的 origin 白名单校验保证
window.parent?.postMessage({ ...msg, v: HARNESS_BRIDGE_VERSION }, '*')
}
const invokeRaw = (method: string, ...args: unknown[]): Promise<unknown> =>
new Promise((resolve, reject) => {
const id = ++seq
pending.set(id, { resolve, reject })
post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:invoke', seq: id, method, args })
})
post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:hello', pluginId })
cached = {
ready,
contractVersion: () => contract,
openView: (view, params) => post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:open-view', view, params }),
setStatus: (id, item) => post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:set-status', id, item }),
onDeepLink(cb) {
deepLinkCbs.add(cb)
return () => void deepLinkCbs.delete(cb)
},
onTheme(cb) {
themeCbs.add(cb)
return () => void themeCbs.delete(cb)
},
onAgentEvent(cb) {
agentCbs.add(cb)
return () => void agentCbs.delete(cb)
},
invoke: invokeRaw as HarnessClient['invoke']
}
return cached
}
export type { OpenViewAction, StatusItem }
+47
View File
@@ -0,0 +1,47 @@
/**
* 插件页 React hooks(peer react,可选使用)。
* import { useHarnessTheme, useDeepLinkParams } from '@aso/plugin-sdk/client'
*/
import { useEffect, useState } from 'react'
import { getHarnessClient, type HarnessAgentEvent, type HarnessTheme } from './client'
/** 当前主题(握手完成前为 null) */
export function useHarnessTheme(pluginId: string): HarnessTheme | null {
const [theme, setTheme] = useState<HarnessTheme | null>(null)
useEffect(() => {
const client = getHarnessClient(pluginId)
let latest: HarnessTheme | null = null
const off = client.onTheme((t) => {
latest = t
setTheme(t)
})
void client.ready.then(() => setTheme(latest))
return off
}, [pluginId])
return theme
}
/** 最近一次深链参数(页面被 openView 打开/更新时变化;无深链为 undefined) */
export function useDeepLinkParams(pluginId: string): unknown {
const [params, setParams] = useState<unknown>(undefined)
useEffect(() => {
const client = getHarnessClient(pluginId)
const off = client.onDeepLink(setParams)
return off
}, [pluginId])
return params
}
/** 订阅宿主中继的 agent 事件(handler 变化不重订阅) */
export function useAgentEvent(pluginId: string, handler: (event: HarnessAgentEvent) => void): void {
useEffect(() => {
const client = getHarnessClient(pluginId)
const ref = { handler }
const off = client.onAgentEvent((event) => ref.handler(event))
return () => {
ref.handler = () => undefined
off()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId])
}
+7
View File
@@ -0,0 +1,7 @@
/**
* @aso/plugin-sdk 主入口(插件 main.mjs / 内置插件工厂使用)。
* 插件页(iframe)请改用 '@aso/plugin-sdk/client' 子入口(不含 pi SDK 类型依赖路径)。
*/
export * from './manifest'
export * from './status'
export * from './plugin'
+37
View File
@@ -0,0 +1,37 @@
/**
* 插件清单类型(插件根目录 manifest.json,内置/外置共用一份契约)。
* 本模块不得引入 pi SDK 或 React 类型——它同时被主进程与插件页消费。
*/
/** 插件 id 命名规则:小写字母开头,小写字母/数字/连字符 */
export const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]*$/
export interface PluginManifest {
/** 唯一 id(内置/外置同 id 时高 semver 胜、平级外置胜) */
id: string
name: string
/** semver;在线更新与遮蔽判定依据 */
version: string
/** 最低 harness 契约版本(semver),不满足拒载 */
minContract: string
description?: string
/** 侧栏入口图标:svg 字符串;缺省用宿主默认字形 */
icon?: string
/** 侧栏入口排序(升序,只在插件之间排序;核心入口恒在前) */
order?: number
/** 在右侧 SidePanel 出现为一个 tab */
panel?: boolean
/** 外置插件入口(相对插件根的路径)。
* 内置插件留空:UI 组件与 main 工厂编译进宿主 bundle。 */
entry?: {
/** 插件页入口(iframe 加载 `plugin://<id>/<page>`) */
page?: string
/** 主进程模块(动态 import,默认导出 definePlugin 工厂) */
main?: string
}
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
replacesNative?: boolean
/** 更新索引地址(JSON;格式见 docs/plugins.md) */
updateURL?: string
}
+3
View File
@@ -0,0 +1,3 @@
/** '@aso/plugin-sdk/client' 子入口:插件页(iframe)桥接 client + hooks */
export * from './client'
export * from './hooks'
+44
View File
@@ -0,0 +1,44 @@
/**
* 主进程侧插件契约(main.mjs / 内置插件工厂共用)。
* 生命周期对齐 pi 原生扩展:工厂函数收到 api,返回 dispose(可选)。
*/
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
import type { PluginManifest } from './manifest'
import type { StatusItem, ToolbarButtonDef } from './status'
/** 插件私有 KV 存储(主进程落盘 userData/aso-plugin-storage.json,按 pluginId 命名空间隔离) */
export interface PluginStorage {
get<T>(key: string): T | undefined
set(key: string, value: unknown): void
delete(key: string): void
}
/** harness 侧 UI 能力(Electron 版槽位,语义对位 pi TUI 的 ctx.ui.*)。
* 内联槽位全部数据驱动;插件页内 UI 由插件页自理,不经此 API。 */
export interface HarnessUiApi {
/** 打开视图('chat' 或插件 id),携可选深链参数 */
openView(view: string, params?: unknown): void
/** 写/更新一条状态胶囊(按 id 键控;插件 dispose 时宿主自动清理其全部条目) */
setStatusItem(id: string, item: StatusItem): void
removeStatusItem(id: string): void
/** 注册输入区工具行按钮(按 id 键控;dispose 自动清理) */
registerToolbarButton(button: ToolbarButtonDef): void
removeToolbarButton(id: string): void
}
/** 插件工厂收到的完整 API */
export interface HarnessPluginApi {
/** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */
pi: ExtensionAPI
ui: HarnessUiApi
storage: PluginStorage
manifest: PluginManifest
}
/** 插件工厂:同步或异步返回 dispose */
export type HarnessPluginFactory = (api: HarnessPluginApi) => void | (() => void) | Promise<void | (() => void)>
/** 身份函数:为插件作者提供完整类型推断 */
export function definePlugin(factory: HarnessPluginFactory): HarnessPluginFactory {
return factory
}
+29
View File
@@ -0,0 +1,29 @@
/**
* 内联槽位的数据类型(B2 状态胶囊 / B5 工具栏按钮)。
* 契约原则:内联 UI 一律"数据 + 深链",不注册组件——外置插件只有
* iframe 页有能力渲染自己的 React;宿主统一按数据渲染这些小 UI。
*/
/** 深链:点击行为 = 打开某个视图('chat' 或插件 id)并携带参数 */
export interface OpenViewAction {
view: string
params?: unknown
}
/** 状态胶囊条目(TitleBar 胶囊区 / 聊天区浮动胶囊共用一份数据) */
export interface StatusItem {
text: string
tone?: 'default' | 'accent' | 'warn' | 'danger'
/** 点击行为:深链;缺省不可点击 */
openView?: OpenViewAction
}
/** 输入区工具行按钮 */
export interface ToolbarButtonDef {
id: string
title: string
/** svg 字符串(16-20px 线条风格,宿主按 currentColor 渲染) */
icon?: string
/** 点击行为:深链;缺省不可点击 */
openView?: OpenViewAction
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}