feat: 外置插件一等公民——entry.renderer 宿主内 React 组件(.pi/extensions 一等插件源 + 开发版门控)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
Reference in New Issue
Block a user