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
+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 }