feat: 外置插件一等公民——entry.renderer 宿主内 React 组件(.pi/extensions 一等插件源 + 开发版门控)

This commit is contained in:
Rick Ye
2026-09-11 00:46:52 +08:00
parent 52f93ddc84
commit 8b94d42930
11 changed files with 291 additions and 53 deletions
+3 -1
View File
@@ -20,7 +20,7 @@ import {
type Skill
} from '@earendil-works/pi-coding-agent'
import { ApprovalService, createApprovalExtension } from './approval'
import { getPluginHost } from '../plugins/plugin-host'
import { EXTERNAL_PLUGINS_ENABLED, getPluginHost } from '../plugins/plugin-host'
import { browserToolNames } from '../tools/browser-tools'
import type {
AgentUiEvent,
@@ -292,6 +292,8 @@ export class AgentService {
const cwd = this.cwd
const agentDir = getAgentDir()
this.settings = this.loadSettings()
// 项目级第三方插件目录随工作区同步(.pi/extensions;目录变更即重扫描插件列表)
getPluginHost().setProjectExtensionsDir(EXTERNAL_PLUGINS_ENABLED ? cwd : null)
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
if (!this.approvalsRegistered) {
this.approvalsRegistered = true
+72 -36
View File
@@ -1,12 +1,19 @@
/**
* 插件宿主(主进程):
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
* - 外置插件:userData/plugins/<id>/manifest.json + 可选 main.mjs(动态 import)
* - 同 id 高 semver 胜、平级外置胜;外置遮蔽组件级内置需 manifest.replacesNative
* - 外置插件:userData/plugins/<id>、项目 .pi/extensions/<id>、全局 ~/.pi/agent/extensions/<id>
* (均为 manifest.json + 可选 main.mjs / entry.renderer / entry.page)
* - 同 id 高 semver 胜、平级先扫者胜(userData > 项目 > 全局);外置遮蔽组件级内置需
* manifest.replacesNative
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
* - plugin:// 协议把插件目录映射给 iframe 页(含路径穿越防护)
* - plugin:// 协议把插件目录映射给 iframe 页与渲染层 renderer 模块(含路径穿越防护)
* - entry.renderer(渲染层 React 组件模块)在渲染层经动态 import 装载,React 用宿主实例
*
* 外置装载是 build 级开关:EXTERNAL_PLUGINS_ENABLED——开发版(未打包)开启,
* 用户版(打包)关闭(M5 ed25519 验签 + 开发者模式落地后再放开;QA 可用
* ASO_EXTERNAL_PLUGINS=1 豁免)。关闭时仅内置插件生效。
*
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
* ed25519 验签 + 开发者模式豁免)。
@@ -15,6 +22,7 @@ import { app, BrowserWindow, net, protocol } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { getAgentDir } from '@earendil-works/pi-coding-agent'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
@@ -23,6 +31,9 @@ import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
export const HARNESS_CONTRACT_VERSION = '0.1.0'
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
interface ResolvedPlugin {
manifest: PluginManifest
source: 'builtin' | 'external'
@@ -57,6 +68,8 @@ function compareSemver(a: string, b: string): number {
class PluginHost {
private resolved = new Map<string, ResolvedPlugin>()
private disabled = new Set<string>()
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
private projectExtensionsDir: string | null = null
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
private rebuildRuntime: (() => Promise<void>) | null = null
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
@@ -109,7 +122,16 @@ class PluginHost {
this.rebuildRuntime = fn
}
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建) */
/** 同步项目级扩展目录(工作区切换时变更即重扫描;仅通知 UI,不重建 runtime) */
setProjectExtensionsDir(dir: string | null): void {
const next = dir ?? null
if ((this.projectExtensionsDir ?? null) === next) return
this.projectExtensionsDir = next
void this.scan()
}
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建)。
* 外置源优先级:userData/plugins > 项目 .pi/extensions > 全局 ~/.pi/agent/extensions。 */
async scan(): Promise<void> {
const byId = new Map<string, ResolvedPlugin>()
@@ -122,39 +144,45 @@ class PluginHost {
})
}
if (existsSync(this.externalRoot)) {
let entries: import('node:fs').Dirent[]
try {
entries = readdirSync(this.externalRoot, { withFileTypes: true })
} catch {
entries = []
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dir = join(this.externalRoot, entry.name)
const manifest = this.readManifest(dir)
if (!manifest) continue
const existing = byId.get(manifest.id)
if (existing) {
const cmp = compareSemver(manifest.version, existing.manifest.version)
// 外置仅在严格更高版本时遮蔽内置;平级外置胜仅适用于同为外置的场景(此处 existing 必为内置)
if (cmp <= 0) continue
// 组件级内置只能被显式声明降级替代的外置遮蔽
if (existing.componentKey && !manifest.replacesNative) {
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}`
if (EXTERNAL_PLUGINS_ENABLED) {
const roots = [this.externalRoot]
if (this.projectExtensionsDir) roots.push(this.projectExtensionsDir)
roots.push(join(getAgentDir(), 'extensions'))
for (const root of roots) {
if (!existsSync(root)) continue
let entries: import('node:fs').Dirent[]
try {
entries = readdirSync(root, { withFileTypes: true })
} catch {
entries = []
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dir = join(root, entry.name)
const manifest = this.readManifest(dir)
if (!manifest) continue
const existing = byId.get(manifest.id)
if (existing) {
const cmp = compareSemver(manifest.version, existing.manifest.version)
// 仅严格更高版本可遮蔽;平级先扫者胜
if (cmp <= 0) continue
// 组件级内置只能被显式声明降级替代的外置遮蔽
if (existing.componentKey && !manifest.replacesNative) {
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}`
continue
}
}
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
byId.set(manifest.id, {
manifest,
source: 'external',
dir,
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
})
continue
}
byId.set(manifest.id, { manifest, source: 'external', dir })
}
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
byId.set(manifest.id, {
manifest,
source: 'external',
dir,
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
})
continue
}
byId.set(manifest.id, { manifest, source: 'external', dir })
}
}
@@ -183,7 +211,8 @@ class PluginHost {
raw.entry && typeof raw.entry === 'object'
? {
page: typeof raw.entry.page === 'string' ? raw.entry.page : undefined,
main: typeof raw.entry.main === 'string' ? raw.entry.main : undefined
main: typeof raw.entry.main === 'string' ? raw.entry.main : undefined,
renderer: typeof raw.entry.renderer === 'string' ? raw.entry.renderer : undefined
}
: undefined,
replacesNative: raw.replacesNative === true,
@@ -250,6 +279,7 @@ class PluginHost {
hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
hasComponent: Boolean(p.componentKey),
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
error: p.error
}))
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
@@ -462,11 +492,17 @@ class PluginHost {
if (!file || !existsSync(file)) return new Response('not found', { status: 404 })
try {
const res = await net.fetch(pathToFileURL(file).toString())
// .mjs/.js 必须以 JS MIME 返回——渲染层动态 import(ESM 模块加载)强校验 content-type
const lower = file.toLowerCase()
const contentType =
lower.endsWith('.mjs') || lower.endsWith('.js')
? 'text/javascript'
: (res.headers.get('content-type') ?? 'application/octet-stream')
// no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览
return new Response(res.body, {
status: res.status,
headers: {
'content-type': res.headers.get('content-type') ?? 'application/octet-stream',
'content-type': contentType,
'cache-control': 'no-store'
}
})
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' plugin:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:"
/>
<title>Inkreach Agent</title>
</head>
+17 -6
View File
@@ -13,6 +13,7 @@ import { Search } from './components/Search'
import { BrowserPanel } from './components/browser/BrowserPanel'
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
import { useExternalPluginComponents, usePluginRendererLoader } from './plugins/renderer-host'
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
@@ -99,6 +100,7 @@ export default function App(): JSX.Element {
// ---- 插件系统:视图切换('chat' | 插件 id)+ 深链参数 ----
const plugins = usePluginList()
const pluginsUi = usePluginsUiState()
const externalComponents = useExternalPluginComponents()
const [activeView, setActiveView] = useState<{ view: string; params?: unknown }>({ view: 'chat' })
const activeViewRef = useRef(activeView)
activeViewRef.current = activeView
@@ -107,11 +109,13 @@ export default function App(): JSX.Element {
pluginByIdRef.current = pluginById
const entryPlugins = useMemo(() => sidebarPlugins(plugins), [plugins])
const panelPlugins = useMemo(
() => plugins.filter((p) => p.enabled && p.manifest.panel && p.hasPage),
() => plugins.filter((p) => p.enabled && p.manifest.panel && (p.hasPage || p.hasRenderer)),
[plugins]
)
/** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */
const [pluginReloadToken, setPluginReloadToken] = useState(0)
// 外置插件 renderer 模块装载(entry.renderer → 宿主内 React 组件;仅开发版)
usePluginRendererLoader(plugins, pluginReloadToken)
/** 打开视图('chat' 或插件 id,携深链参数);插件声明 panel 时联动展开其面板标签 */
const handleOpenView = useCallback((view: string, params?: unknown) => {
@@ -120,7 +124,7 @@ export default function App(): JSX.Element {
return
}
const plugin = pluginByIdRef.current.get(view)
if (!plugin || !plugin.enabled || (!plugin.hasComponent && !plugin.hasPage)) return
if (!plugin || !plugin.enabled || (!plugin.hasComponent && !plugin.hasPage && !plugin.hasRenderer)) return
setActiveView({ view, params })
if (plugin.manifest.panel) {
setSide((s) => ({
@@ -335,13 +339,18 @@ export default function App(): JSX.Element {
}
const plugin = pluginById.get(id)
if (!plugin || !plugin.enabled || !plugin.manifest.panel) return []
const PanelComponent = plugin.hasRenderer ? externalComponents.get(id)?.panel : undefined
return [
{
id,
title: plugin.manifest.name,
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
content: (
content: PanelComponent ? (
<PanelComponent />
) : plugin.hasPage ? (
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
) : (
<div className="flex h-full items-center justify-center text-[12px] text-ink-3">插件未提供页面</div>
)
}
]
@@ -355,11 +364,13 @@ export default function App(): JSX.Element {
}))
]
// 当前插件视图(非 chat 时渲染其 Main:内置组件 or 外置 iframe 页)
// 当前插件视图(非 chat 时渲染其 Main:内置组件 / 外置 renderer 注册组件 / 外置 iframe 页)
const activePlugin: PluginInfo | undefined =
activeView.view === 'chat' ? undefined : pluginById.get(activeView.view)
const ActivePluginComponent: ComponentType | undefined = activePlugin?.hasComponent
? BUILTIN_PLUGIN_COMPONENTS[activeView.view]
const ActivePluginComponent: ComponentType | undefined = activePlugin
? activePlugin.hasComponent
? BUILTIN_PLUGIN_COMPONENTS[activeView.view]
: externalComponents.get(activeView.view)?.view
: undefined
return (
@@ -0,0 +1,96 @@
/**
* 外置插件渲染层宿主(entry.renderer 动态装载):
* - 启动即注入 window.__ASO_PLUGIN_HOST__(宿主 React 单实例——插件组件必须用它创建,
* 自带 React 副本会导致 hooks 失效)
* - 插件 renderer 模块经 plugin://<id>/<file>?t=<token> 动态 import(no-store + cache-bust,
* 列表变化/热重载即整模块重装),默认导出 mount(ctx) 注册 view/panel 组件
* - 仅开发版装载(与主进程 EXTERNAL_PLUGINS_ENABLED 同开关语义;用户版主进程不下发外置条目)
* - 错误隔离到单插件:装载/注册失败仅 console.error 并清空该插件注册,不影响宿主与其他插件;
* 组件渲染期崩溃由 App 层 PluginErrorBoundary 兜底
*/
import { useEffect, useSyncExternalStore } from 'react'
import type { ComponentType } from 'react'
import * as React from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import type { PluginHostGlobal, PluginRendererContext, PluginRendererModule } from '@aso/plugin-sdk/renderer'
import type { PluginInfo } from '@shared/types'
/** 插件已注册组件(按槽位;缺省 = 未注册该槽位) */
interface PluginComponents {
view?: ComponentType
panel?: ComponentType
}
const registry = new Map<string, PluginComponents>()
const listeners = new Set<() => void>()
let version = 0
function upsert(pluginId: string, patch: PluginComponents): void {
registry.set(pluginId, { ...registry.get(pluginId), ...patch })
version++
for (const fn of listeners) fn()
}
function remove(pluginId: string): void {
if (!registry.delete(pluginId)) return
version++
for (const fn of listeners) fn()
}
function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
/** 外置插件注册表(version 变化触发重渲染;组件本身从 registry 读取) */
export function useExternalPluginComponents(): Map<string, PluginComponents> {
useSyncExternalStore(subscribe, () => version)
return registry
}
// 宿主全局:插件构建期把 react / react/jsx-runtime 别名到这里的实例(见 docs/plugins.md)
;(window as unknown as Record<'__ASO_PLUGIN_HOST__', PluginHostGlobal | undefined>).__ASO_PLUGIN_HOST__ = {
react: React,
jsxRuntime
}
/**
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌变化即重装。
* 不在列表(禁用/卸载/关闭开关)的插件注册被移除,其组件随之卸载。
*/
export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: number): void {
useEffect(() => {
if (!import.meta.env.DEV) return
let cancelled = false
const keep = new Set<string>()
for (const plugin of plugins) {
const file = plugin.manifest.entry?.renderer
if (!plugin.enabled || plugin.source !== 'external' || !file) continue
const id = plugin.manifest.id
keep.add(id)
import(/* @vite-ignore */ `plugin://${id}/${file}?t=${reloadToken}`)
.then((mod) => {
if (cancelled) return
const mount = (mod as { default?: unknown }).default
if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数')
const ctx: PluginRendererContext = {
manifest: plugin.manifest,
react: React,
registerView: (component) => upsert(id, { view: component }),
registerPanel: (component) => upsert(id, { panel: component })
}
;(mount as PluginRendererModule)(ctx)
})
.catch((err: unknown) => {
console.error(`[plugins] ${id} renderer 装载失败:`, err)
remove(id)
})
}
for (const id of [...registry.keys()]) {
if (!keep.has(id)) remove(id)
}
return () => {
cancelled = true
}
}, [plugins, reloadToken])
}
@@ -53,7 +53,7 @@ export function usePluginsUiState(): PluginsUiState {
/** 仅返回已启用且有侧栏入口的插件(入口排序:order 升序,其次 id) */
export function sidebarPlugins(plugins: PluginInfo[]): PluginInfo[] {
return plugins
.filter((p) => p.enabled && (p.hasComponent || p.hasPage))
.filter((p) => p.enabled && (p.hasComponent || p.hasPage || p.hasRenderer))
.sort(
(a, b) =>
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) ||
+2
View File
@@ -374,6 +374,8 @@ export interface PluginInfo {
hasPage: boolean
/** 有宿主树组件(仅内置;外置遮蔽需 replacesNative 声明) */
hasComponent: boolean
/** 有渲染层模块(entry.renderer,宿主动态 import 后注册宿主内 React 组件) */
hasRenderer: boolean
/** 装载/激活错误(不影响其他插件) */
error?: string
}