feat: 外置插件一等公民——entry.renderer 宿主内 React 组件(.pi/extensions 一等插件源 + 开发版门控)
This commit is contained in:
@@ -20,7 +20,7 @@ import {
|
|||||||
type Skill
|
type Skill
|
||||||
} from '@earendil-works/pi-coding-agent'
|
} from '@earendil-works/pi-coding-agent'
|
||||||
import { ApprovalService, createApprovalExtension } from './approval'
|
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 { browserToolNames } from '../tools/browser-tools'
|
||||||
import type {
|
import type {
|
||||||
AgentUiEvent,
|
AgentUiEvent,
|
||||||
@@ -292,6 +292,8 @@ export class AgentService {
|
|||||||
const cwd = this.cwd
|
const cwd = this.cwd
|
||||||
const agentDir = getAgentDir()
|
const agentDir = getAgentDir()
|
||||||
this.settings = this.loadSettings()
|
this.settings = this.loadSettings()
|
||||||
|
// 项目级第三方插件目录随工作区同步(.pi/extensions;目录变更即重扫描插件列表)
|
||||||
|
getPluginHost().setProjectExtensionsDir(EXTERNAL_PLUGINS_ENABLED ? cwd : null)
|
||||||
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
|
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
|
||||||
if (!this.approvalsRegistered) {
|
if (!this.approvalsRegistered) {
|
||||||
this.approvalsRegistered = true
|
this.approvalsRegistered = true
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* 插件宿主(主进程):
|
* 插件宿主(主进程):
|
||||||
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
|
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
|
||||||
* - 外置插件:userData/plugins/<id>/manifest.json + 可选 main.mjs(动态 import)
|
* - 外置插件:userData/plugins/<id>、项目 .pi/extensions/<id>、全局 ~/.pi/agent/extensions/<id>
|
||||||
* - 同 id 高 semver 胜、平级外置胜;外置遮蔽组件级内置需 manifest.replacesNative
|
* (均为 manifest.json + 可选 main.mjs / entry.renderer / entry.page)
|
||||||
|
* - 同 id 高 semver 胜、平级先扫者胜(userData > 项目 > 全局);外置遮蔽组件级内置需
|
||||||
|
* manifest.replacesNative
|
||||||
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
|
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
|
||||||
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
|
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
|
||||||
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
|
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
|
||||||
* - plugin:// 协议把插件目录映射给 iframe 页(含路径穿越防护)
|
* - plugin:// 协议把插件目录映射给 iframe 页与渲染层 renderer 模块(含路径穿越防护)
|
||||||
|
* - entry.renderer(渲染层 React 组件模块)在渲染层经动态 import 装载,React 用宿主实例
|
||||||
|
*
|
||||||
|
* 外置装载是 build 级开关:EXTERNAL_PLUGINS_ENABLED——开发版(未打包)开启,
|
||||||
|
* 用户版(打包)关闭(M5 ed25519 验签 + 开发者模式落地后再放开;QA 可用
|
||||||
|
* ASO_EXTERNAL_PLUGINS=1 豁免)。关闭时仅内置插件生效。
|
||||||
*
|
*
|
||||||
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
||||||
* ed25519 验签 + 开发者模式豁免)。
|
* ed25519 验签 + 开发者模式豁免)。
|
||||||
@@ -15,6 +22,7 @@ import { app, BrowserWindow, net, protocol } from 'electron'
|
|||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||||
import { join, resolve, sep } from 'node:path'
|
import { join, resolve, sep } from 'node:path'
|
||||||
import { pathToFileURL } from 'node:url'
|
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 { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
|
||||||
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
||||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||||
@@ -23,6 +31,9 @@ import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
|||||||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||||
export const HARNESS_CONTRACT_VERSION = '0.1.0'
|
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 {
|
interface ResolvedPlugin {
|
||||||
manifest: PluginManifest
|
manifest: PluginManifest
|
||||||
source: 'builtin' | 'external'
|
source: 'builtin' | 'external'
|
||||||
@@ -57,6 +68,8 @@ function compareSemver(a: string, b: string): number {
|
|||||||
class PluginHost {
|
class PluginHost {
|
||||||
private resolved = new Map<string, ResolvedPlugin>()
|
private resolved = new Map<string, ResolvedPlugin>()
|
||||||
private disabled = new Set<string>()
|
private disabled = new Set<string>()
|
||||||
|
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
|
||||||
|
private projectExtensionsDir: string | null = null
|
||||||
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
|
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
|
||||||
private rebuildRuntime: (() => Promise<void>) | null = null
|
private rebuildRuntime: (() => Promise<void>) | null = null
|
||||||
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
||||||
@@ -109,7 +122,16 @@ class PluginHost {
|
|||||||
this.rebuildRuntime = fn
|
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> {
|
async scan(): Promise<void> {
|
||||||
const byId = new Map<string, ResolvedPlugin>()
|
const byId = new Map<string, ResolvedPlugin>()
|
||||||
|
|
||||||
@@ -122,22 +144,27 @@ class PluginHost {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(this.externalRoot)) {
|
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[]
|
let entries: import('node:fs').Dirent[]
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(this.externalRoot, { withFileTypes: true })
|
entries = readdirSync(root, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
entries = []
|
entries = []
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue
|
if (!entry.isDirectory()) continue
|
||||||
const dir = join(this.externalRoot, entry.name)
|
const dir = join(root, entry.name)
|
||||||
const manifest = this.readManifest(dir)
|
const manifest = this.readManifest(dir)
|
||||||
if (!manifest) continue
|
if (!manifest) continue
|
||||||
const existing = byId.get(manifest.id)
|
const existing = byId.get(manifest.id)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const cmp = compareSemver(manifest.version, existing.manifest.version)
|
const cmp = compareSemver(manifest.version, existing.manifest.version)
|
||||||
// 外置仅在严格更高版本时遮蔽内置;平级外置胜仅适用于同为外置的场景(此处 existing 必为内置)
|
// 仅严格更高版本可遮蔽;平级先扫者胜
|
||||||
if (cmp <= 0) continue
|
if (cmp <= 0) continue
|
||||||
// 组件级内置只能被显式声明降级替代的外置遮蔽
|
// 组件级内置只能被显式声明降级替代的外置遮蔽
|
||||||
if (existing.componentKey && !manifest.replacesNative) {
|
if (existing.componentKey && !manifest.replacesNative) {
|
||||||
@@ -157,6 +184,7 @@ class PluginHost {
|
|||||||
byId.set(manifest.id, { manifest, source: 'external', dir })
|
byId.set(manifest.id, { manifest, source: 'external', dir })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.resolved = byId
|
this.resolved = byId
|
||||||
await this.loadExternalMains()
|
await this.loadExternalMains()
|
||||||
@@ -183,7 +211,8 @@ class PluginHost {
|
|||||||
raw.entry && typeof raw.entry === 'object'
|
raw.entry && typeof raw.entry === 'object'
|
||||||
? {
|
? {
|
||||||
page: typeof raw.entry.page === 'string' ? raw.entry.page : undefined,
|
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,
|
: undefined,
|
||||||
replacesNative: raw.replacesNative === true,
|
replacesNative: raw.replacesNative === true,
|
||||||
@@ -250,6 +279,7 @@ class PluginHost {
|
|||||||
hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
|
hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
|
||||||
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
|
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
|
||||||
hasComponent: Boolean(p.componentKey),
|
hasComponent: Boolean(p.componentKey),
|
||||||
|
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
||||||
error: p.error
|
error: p.error
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
|
.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 })
|
if (!file || !existsSync(file)) return new Response('not found', { status: 404 })
|
||||||
try {
|
try {
|
||||||
const res = await net.fetch(pathToFileURL(file).toString())
|
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 即取新文件,避免缓存妨碍开发预览
|
// no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览
|
||||||
return new Response(res.body, {
|
return new Response(res.body, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': res.headers.get('content-type') ?? 'application/octet-stream',
|
'content-type': contentType,
|
||||||
'cache-control': 'no-store'
|
'cache-control': 'no-store'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
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>
|
<title>Inkreach Agent</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Search } from './components/Search'
|
|||||||
import { BrowserPanel } from './components/browser/BrowserPanel'
|
import { BrowserPanel } from './components/browser/BrowserPanel'
|
||||||
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
|
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
|
||||||
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
|
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
|
||||||
|
import { useExternalPluginComponents, usePluginRendererLoader } from './plugins/renderer-host'
|
||||||
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
|
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
|
||||||
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
|
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
|
||||||
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
|
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
|
||||||
@@ -99,6 +100,7 @@ export default function App(): JSX.Element {
|
|||||||
// ---- 插件系统:视图切换('chat' | 插件 id)+ 深链参数 ----
|
// ---- 插件系统:视图切换('chat' | 插件 id)+ 深链参数 ----
|
||||||
const plugins = usePluginList()
|
const plugins = usePluginList()
|
||||||
const pluginsUi = usePluginsUiState()
|
const pluginsUi = usePluginsUiState()
|
||||||
|
const externalComponents = useExternalPluginComponents()
|
||||||
const [activeView, setActiveView] = useState<{ view: string; params?: unknown }>({ view: 'chat' })
|
const [activeView, setActiveView] = useState<{ view: string; params?: unknown }>({ view: 'chat' })
|
||||||
const activeViewRef = useRef(activeView)
|
const activeViewRef = useRef(activeView)
|
||||||
activeViewRef.current = activeView
|
activeViewRef.current = activeView
|
||||||
@@ -107,11 +109,13 @@ export default function App(): JSX.Element {
|
|||||||
pluginByIdRef.current = pluginById
|
pluginByIdRef.current = pluginById
|
||||||
const entryPlugins = useMemo(() => sidebarPlugins(plugins), [plugins])
|
const entryPlugins = useMemo(() => sidebarPlugins(plugins), [plugins])
|
||||||
const panelPlugins = useMemo(
|
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]
|
[plugins]
|
||||||
)
|
)
|
||||||
/** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */
|
/** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */
|
||||||
const [pluginReloadToken, setPluginReloadToken] = useState(0)
|
const [pluginReloadToken, setPluginReloadToken] = useState(0)
|
||||||
|
// 外置插件 renderer 模块装载(entry.renderer → 宿主内 React 组件;仅开发版)
|
||||||
|
usePluginRendererLoader(plugins, pluginReloadToken)
|
||||||
|
|
||||||
/** 打开视图('chat' 或插件 id,携深链参数);插件声明 panel 时联动展开其面板标签 */
|
/** 打开视图('chat' 或插件 id,携深链参数);插件声明 panel 时联动展开其面板标签 */
|
||||||
const handleOpenView = useCallback((view: string, params?: unknown) => {
|
const handleOpenView = useCallback((view: string, params?: unknown) => {
|
||||||
@@ -120,7 +124,7 @@ export default function App(): JSX.Element {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const plugin = pluginByIdRef.current.get(view)
|
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 })
|
setActiveView({ view, params })
|
||||||
if (plugin.manifest.panel) {
|
if (plugin.manifest.panel) {
|
||||||
setSide((s) => ({
|
setSide((s) => ({
|
||||||
@@ -335,13 +339,18 @@ export default function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
const plugin = pluginById.get(id)
|
const plugin = pluginById.get(id)
|
||||||
if (!plugin || !plugin.enabled || !plugin.manifest.panel) return []
|
if (!plugin || !plugin.enabled || !plugin.manifest.panel) return []
|
||||||
|
const PanelComponent = plugin.hasRenderer ? externalComponents.get(id)?.panel : undefined
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
title: plugin.manifest.name,
|
title: plugin.manifest.name,
|
||||||
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
|
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
|
||||||
content: (
|
content: PanelComponent ? (
|
||||||
|
<PanelComponent />
|
||||||
|
) : plugin.hasPage ? (
|
||||||
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
|
<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 =
|
const activePlugin: PluginInfo | undefined =
|
||||||
activeView.view === 'chat' ? undefined : pluginById.get(activeView.view)
|
activeView.view === 'chat' ? undefined : pluginById.get(activeView.view)
|
||||||
const ActivePluginComponent: ComponentType | undefined = activePlugin?.hasComponent
|
const ActivePluginComponent: ComponentType | undefined = activePlugin
|
||||||
|
? activePlugin.hasComponent
|
||||||
? BUILTIN_PLUGIN_COMPONENTS[activeView.view]
|
? BUILTIN_PLUGIN_COMPONENTS[activeView.view]
|
||||||
|
: externalComponents.get(activeView.view)?.view
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
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) */
|
/** 仅返回已启用且有侧栏入口的插件(入口排序:order 升序,其次 id) */
|
||||||
export function sidebarPlugins(plugins: PluginInfo[]): PluginInfo[] {
|
export function sidebarPlugins(plugins: PluginInfo[]): PluginInfo[] {
|
||||||
return plugins
|
return plugins
|
||||||
.filter((p) => p.enabled && (p.hasComponent || p.hasPage))
|
.filter((p) => p.enabled && (p.hasComponent || p.hasPage || p.hasRenderer))
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) ||
|
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) ||
|
||||||
|
|||||||
@@ -374,6 +374,8 @@ export interface PluginInfo {
|
|||||||
hasPage: boolean
|
hasPage: boolean
|
||||||
/** 有宿主树组件(仅内置;外置遮蔽需 replacesNative 声明) */
|
/** 有宿主树组件(仅内置;外置遮蔽需 replacesNative 声明) */
|
||||||
hasComponent: boolean
|
hasComponent: boolean
|
||||||
|
/** 有渲染层模块(entry.renderer,宿主动态 import 后注册宿主内 React 组件) */
|
||||||
|
hasRenderer: boolean
|
||||||
/** 装载/激活错误(不影响其他插件) */
|
/** 装载/激活错误(不影响其他插件) */
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-7
@@ -28,12 +28,14 @@
|
|||||||
"icon": "<svg…/>", // 侧栏/面板图标(svg 字符串;宿主清除事件属性与 script)
|
"icon": "<svg…/>", // 侧栏/面板图标(svg 字符串;宿主清除事件属性与 script)
|
||||||
"order": 10, // 侧栏排序(只在插件间;核心入口恒在前)
|
"order": 10, // 侧栏排序(只在插件间;核心入口恒在前)
|
||||||
"panel": true, // 在右侧 SidePanel 出现一个 tab
|
"panel": true, // 在右侧 SidePanel 出现一个 tab
|
||||||
"entry": { "page": "index.html", "main": "main.mjs" }, // 内置插件省略 entry
|
"entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry
|
||||||
"replacesNative": false,
|
"replacesNative": false,
|
||||||
"updateURL": "https://…/index.json"
|
"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` 主入口)
|
## 主进程插件契约(`@aso/plugin-sdk` 主入口)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -75,13 +77,56 @@ useHarnessTheme('hotwords') // { dark, accent }(深浅色/acce
|
|||||||
- 安全:宿主校验 `event.source === iframe.contentWindow` **且** `event.origin === 'plugin://<id>'`;sandbox 无 `allow-top-navigation`;iframe 页**无** window.api 直达
|
- 安全:宿主校验 `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 编译
|
- 主题连续性:插件页 `<link rel="stylesheet" href="peer://assets/tokens.css">`(宿主 beautifului.css 的 token 表,深浅色由 `.dark` 类 + 下发的 accent 生效);建议插件自带 Tailwind 按同 token 编译
|
||||||
|
|
||||||
|
## 渲染层模块契约(`@aso/plugin-sdk/renderer`)
|
||||||
|
|
||||||
|
外置插件以 React 组件形态进入宿主界面(与内置同槽位),不再是 iframe 物理隔离:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// manifest.json
|
||||||
|
{ "id": "demo-ui", "panel": true, "entry": { "renderer": "renderer.mjs" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||||
|
--jsx=automatic \
|
||||||
|
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
||||||
|
```
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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`);错误隔离到单插件(装载失败仅 console.error,渲染崩溃由 PluginErrorBoundary 兜底)
|
||||||
|
|
||||||
## UI 槽位总览(对位 pi 的 ctx.ui)
|
## UI 槽位总览(对位 pi 的 ctx.ui)
|
||||||
|
|
||||||
| 槽位 | 机制 | 状态 |
|
| 槽位 | 机制 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
|
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
|
||||||
| 插件整页 Main | 内置组件级 / 外置 iframe;聊天列 display:none 保活 | ✅ |
|
| 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ |
|
||||||
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件或 iframe) | ✅ |
|
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ |
|
||||||
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见 | ✅ |
|
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见 | ✅ |
|
||||||
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
||||||
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
||||||
@@ -99,12 +144,15 @@ useHarnessTheme('hotwords') // { dark, accent }(深浅色/acce
|
|||||||
## 安全清单
|
## 安全清单
|
||||||
|
|
||||||
- `plugin://` 特权协议:路径解析后必须落在该插件目录内(防穿越);`peer://` 仅暴露白名单资产(tokens.css)
|
- `plugin://` 特权协议:路径解析后必须落在该插件目录内(防穿越);`peer://` 仅暴露白名单资产(tokens.css)
|
||||||
- CSP:宿主页只加 `frame-src plugin:`(iframe 方案不动 script-src)
|
- CSP:宿主页 `frame-src plugin:`(iframe)+ `script-src plugin:`(renderer 模块动态 import);.mjs/.js 强制 JS MIME
|
||||||
- 外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——**签名校验前,仅自行放置的插件目录可装载**(M5 后强制)
|
- 外置装载为 **build 级开关**(`EXTERNAL_PLUGINS_ENABLED`:开发版开/用户版关,`ASO_EXTERNAL_PLUGINS=1` 豁免);外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——**签名校验前,仅自行放置的插件目录可装载**(M5 后强制)
|
||||||
|
- 外置 renderer 模块以渲染层特权运行(无 iframe 沙箱):同样受上述 build 开关门控;组件崩溃不越出 ErrorBoundary
|
||||||
|
|
||||||
## pi 原生目录(M7,未实现)
|
## pi 原生目录(M7)
|
||||||
|
|
||||||
事实:pi resource-loader **默认自动加载** `~/.pi/agent/extensions/*.ts` 与 `<cwd>/.pi/extensions/*.ts`(`noExtensions` 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。M7 计划:设置开关(默认关 → `noExtensions`)+ 管理页可见/单禁 + 参与统一重载。
|
事实: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 模板落地后)
|
## 开发工作流(外置插件,M6 模板落地后)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"description": "Inkreach harness 插件契约:清单/主进程工厂类型 + definePlugin + 插件页桥接 client(主入口给 main.mjs 作者,./client 给 iframe 页作者)",
|
"description": "Inkreach harness 插件契约:清单/主进程工厂类型 + definePlugin + 插件页桥接 client(主入口给 main.mjs 作者,./client 给 iframe 页作者)",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./client": "./src/page.ts"
|
"./client": "./src/page.ts",
|
||||||
|
"./renderer": "./src/renderer.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export interface PluginManifest {
|
|||||||
page?: string
|
page?: string
|
||||||
/** 主进程模块(动态 import,默认导出 definePlugin 工厂) */
|
/** 主进程模块(动态 import,默认导出 definePlugin 工厂) */
|
||||||
main?: string
|
main?: string
|
||||||
|
/** 渲染层模块(宿主动态 import `plugin://<id>/<renderer>`,
|
||||||
|
* 默认导出 mount(ctx);React 组件用宿主实例(ctx.react),见 renderer 契约。
|
||||||
|
* 命名勿用 index.ts/index.js——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载) */
|
||||||
|
renderer?: string
|
||||||
}
|
}
|
||||||
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
|
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
|
||||||
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
|
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 渲染层模块契约(entry.renderer,宿主渲染层动态 import 后调用默认导出 mount)。
|
||||||
|
* 与 main.mjs 契约并列:main 管 Agent 能力(pi),renderer 管宿主内 React UI。
|
||||||
|
*
|
||||||
|
* 单实例约束:组件必须使用 ctx.react(= 宿主 React)创建,插件自带 React 副本
|
||||||
|
* 会导致 hooks 全部失效。JSX 工程的构建配方见 docs/plugins.md(esbuild react 别名 shim)。
|
||||||
|
*/
|
||||||
|
import type { ComponentType } from 'react'
|
||||||
|
import type { PluginManifest } from './manifest'
|
||||||
|
|
||||||
|
/** 渲染层可用的注册槽位(每个插件各槽位一个组件,重复注册覆盖) */
|
||||||
|
export interface PluginRendererSlots {
|
||||||
|
/** 整页视图(侧栏入口点击后的主内容区,与内置组件同槽位) */
|
||||||
|
registerView(component: ComponentType): void
|
||||||
|
/** 右侧 SidePanel tab 内容(需 manifest.panel: true) */
|
||||||
|
registerPanel(component: ComponentType): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRendererContext extends PluginRendererSlots {
|
||||||
|
manifest: PluginManifest
|
||||||
|
/** 宿主 React 命名空间(window.__ASO_PLUGIN_HOST__.react 同一份实例) */
|
||||||
|
react: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** renderer.mjs 默认导出:mount(ctx),同步执行注册后返回 */
|
||||||
|
export type PluginRendererModule = (ctx: PluginRendererContext) => void
|
||||||
|
|
||||||
|
/** 身份函数:为插件作者提供完整类型推断 */
|
||||||
|
export function defineRenderer(module: PluginRendererModule): PluginRendererModule {
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宿主注入到 window.__ASO_PLUGIN_HOST__ 的全局(插件构建期 react 别名 shim 消费) */
|
||||||
|
export interface PluginHostGlobal {
|
||||||
|
react: unknown
|
||||||
|
/** react/jsx-runtime(esbuild --jsx=automatic 时 shim 消费) */
|
||||||
|
jsxRuntime: unknown
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user