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
+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'"
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:"
/>
<title>Inkreach Agent</title>
</head>
+218 -47
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { JSX, ComponentType } from 'react'
import { LuGlobe } from 'react-icons/lu'
import { useAgent } from './hooks/useAgent'
import { useBrowser } from './hooks/useBrowser'
@@ -12,7 +12,13 @@ import { SettingsModal } from './components/SettingsModal'
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 { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
import { applyThemeColor, watchColorScheme } from './lib/theme'
import type { PluginInfo } from '@shared/types'
// ---- 侧边面板(TabPanel):开合 / 标签 / 宽度,跨启动持久化 ----
@@ -29,17 +35,14 @@ interface SideState {
active: string | null
}
/** 面板标签注册表:新标签在这里登记即可进入 TabPanel 通用机制 */
const PANEL_TAB_META: Record<string, { title: string; icon: PanelTabIcon }> = {
browser: { title: '浏览器', icon: LuGlobe }
}
/** 面板标签元数据:browser 为宿主内置;插件标签由 manifest(panel:true)动态注册。
* loadSideState 只做形状校验,非法 id(已卸载插件等)在装配 panelTabs 时跳过。 */
function loadSideState(): SideState {
try {
const raw = localStorage.getItem(SIDE_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<SideState>
const tabs = Array.isArray(parsed.tabs) ? parsed.tabs.filter((t) => t in PANEL_TAB_META) : []
const tabs = Array.isArray(parsed.tabs) ? parsed.tabs.filter((t) => typeof t === 'string') : []
return {
open: Boolean(parsed.open),
width:
@@ -93,6 +96,83 @@ export default function App(): JSX.Element {
const [sideOverlay, setSideOverlay] = useState(false)
const { open: sideOpen, tabs: sideTabIds, active: sideActive, width: sideWidth } = side
// ---- 插件系统:视图切换('chat' | 插件 id)+ 深链参数 ----
const plugins = usePluginList()
const pluginsUi = usePluginsUiState()
const [activeView, setActiveView] = useState<{ view: string; params?: unknown }>({ view: 'chat' })
const activeViewRef = useRef(activeView)
activeViewRef.current = activeView
const pluginById = useMemo(() => new Map(plugins.map((p) => [p.manifest.id, p])), [plugins])
const pluginByIdRef = useRef(pluginById)
pluginByIdRef.current = pluginById
const entryPlugins = useMemo(() => sidebarPlugins(plugins), [plugins])
const panelPlugins = useMemo(
() => plugins.filter((p) => p.enabled && p.manifest.panel && p.hasPage),
[plugins]
)
/** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */
const [pluginReloadToken, setPluginReloadToken] = useState(0)
/** 打开视图('chat' 或插件 id,携深链参数);插件声明 panel 时联动展开其面板标签 */
const handleOpenView = useCallback((view: string, params?: unknown) => {
if (view === 'chat') {
setActiveView({ view: 'chat' })
return
}
const plugin = pluginByIdRef.current.get(view)
if (!plugin || !plugin.enabled || (!plugin.hasComponent && !plugin.hasPage)) return
setActiveView({ view, params })
if (plugin.manifest.panel) {
setSide((s) => ({
...s,
open: true,
tabs: s.tabs.includes(view) ? s.tabs : [...s.tabs, view],
active: view
}))
}
}, [])
// 主进程侧 openView(插件 main 工厂 / 深链按钮)→ 渲染层切视图
useEffect(() => window.api.onPluginsView((change) => handleOpenView(change.view, change.params)), [handleOpenView])
// 插件列表变化(扫描/重载/启停)→ 刷新外置插件页(iframe 换 src 整页重载)
useEffect(
() =>
window.api.onPluginsListChanged(() => {
setPluginReloadToken((t) => t + 1)
}),
[]
)
// 插件列表变化时校正当前视图(插件被禁用/卸载后回落聊天)
useEffect(() => {
setActiveView((current) => {
if (current.view === 'chat') return current
const plugin = pluginById.get(current.view)
return plugin && plugin.enabled ? current : { view: 'chat' }
})
}, [pluginById])
const handleNewSession = useCallback(() => {
void newSession()
setActiveView({ view: 'chat' })
}, [newSession])
const handleOpenSession = useCallback(
(path: string) => {
void openSession(path)
setActiveView({ view: 'chat' })
},
[openSession]
)
const handleOpenPlugin = useCallback(
(id: string) => {
handleOpenView(id, undefined)
},
[handleOpenView]
)
useEffect(() => {
try {
localStorage.setItem(SIDE_STORAGE_KEY, JSON.stringify(side))
@@ -147,16 +227,18 @@ export default function App(): JSX.Element {
return stopWatching
}, [])
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗
// 全局快捷键:Ctrl+N 新会话(全局,切回聊天)/ Ctrl+K 搜索 / Ctrl+, 设置(后两者仅聊天视图)
// Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗(不切视图,回聊天走侧栏点击)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
const inChat = activeViewRef.current.view === 'chat'
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
e.preventDefault()
newSession()
} else if (e.ctrlKey && e.key.toLowerCase() === 'k') {
handleNewSession()
} else if (inChat && e.ctrlKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(true)
} else if (e.ctrlKey && e.key === ',') {
} else if (inChat && e.ctrlKey && e.key === ',') {
e.preventDefault()
setSettingsOpen(true)
} else if (e.ctrlKey && (e.key === '=' || e.key === '+')) {
@@ -175,7 +257,7 @@ export default function App(): JSX.Element {
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [newSession])
}, [handleNewSession])
const handlePickSuggestion = (text: string): void => {
prefillRef.current = text
@@ -206,42 +288,87 @@ export default function App(): JSX.Element {
activeMeta?.firstMessage?.trim() ||
''
// 面板标签:注册表 → PanelTab(内容按 id 装配)
const panelTabs = sideTabIds.map((id) => {
const meta = PANEL_TAB_META[id]
return {
id,
title: meta.title,
icon: meta.icon,
content:
id === 'browser' ? (
<BrowserPanel
state={browser.state}
suspended={settingsOpen || searchOpen || sideOverlay}
onNavigate={browser.navigate}
onGoBack={browser.goBack}
onGoForward={browser.goForward}
onReload={browser.reload}
onNewTab={browser.newTab}
onSelectTab={browser.selectTab}
onCloseTab={browser.closeTab}
onCloseAllTabs={browser.closeBrowser}
onAcknowledgeRisk={browser.acknowledgeRisk}
onDetectSources={browser.detectSources}
onImportProfile={browser.importProfile}
/>
) : null
// 插件面板标签图标(svg 字符串 → 组件;identity 稳定防标签重挂载)
const pluginTabIcons = useMemo(() => {
const map = new Map<string, PanelTabIcon>()
for (const p of panelPlugins) {
const svg = p.manifest.icon
const Icon = ({ size, className }: { size?: number; className?: string }): JSX.Element => (
<PluginIcon svg={svg} size={size ?? 16} className={className} />
)
map.set(p.manifest.id, Icon)
}
return map
}, [panelPlugins])
// 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失)
const browserEnabled = plugins.find((p) => p.manifest.id === 'browser')?.enabled ?? true
// 面板标签:browser(宿主内置组件)+ manifest 声明 panel 的外置插件(iframe 页)
const panelTabs = sideTabIds.flatMap((id) => {
if (id === 'browser') {
if (!browserEnabled) return []
return [
{
id,
title: '浏览器',
icon: LuGlobe as PanelTabIcon,
content: (
<BrowserPanel
state={browser.state}
suspended={settingsOpen || searchOpen || sideOverlay}
onNavigate={browser.navigate}
onGoBack={browser.goBack}
onGoForward={browser.goForward}
onReload={browser.reload}
onNewTab={browser.newTab}
onSelectTab={browser.selectTab}
onCloseTab={browser.closeTab}
onCloseAllTabs={browser.closeBrowser}
onAcknowledgeRisk={browser.acknowledgeRisk}
onDetectSources={browser.detectSources}
onImportProfile={browser.importProfile}
/>
)
}
]
}
const plugin = pluginById.get(id)
if (!plugin || !plugin.enabled || !plugin.manifest.panel) return []
return [
{
id,
title: plugin.manifest.name,
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
content: (
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
)
}
]
})
const panelAvailable = Object.entries(PANEL_TAB_META).map(([id, m]) => ({ id, title: m.title, icon: m.icon }))
const panelAvailable = [
...(browserEnabled ? [{ id: 'browser', title: '浏览器', icon: LuGlobe as PanelTabIcon }] : []),
...panelPlugins.map((p) => ({
id: p.manifest.id,
title: p.manifest.name,
icon: pluginTabIcons.get(p.manifest.id) ?? (LuGlobe as PanelTabIcon)
}))
]
// 当前插件视图(非 chat 时渲染其 Main:内置组件 or 外置 iframe 页)
const activePlugin: PluginInfo | undefined =
activeView.view === 'chat' ? undefined : pluginById.get(activeView.view)
const ActivePluginComponent: ComponentType | undefined = activePlugin?.hasComponent
? BUILTIN_PLUGIN_COMPONENTS[activeView.view]
: undefined
return (
<div className="relative flex h-full w-full bg-page text-ink antialiased">
<SidebarNav
sessionState={state.sessionState}
sessions={sessions}
onNewSession={newSession}
onOpenSession={openSession}
onNewSession={handleNewSession}
onOpenSession={handleOpenSession}
onRenameSession={renameSession}
onDeleteSession={deleteSession}
onOpenSearch={() => setSearchOpen(true)}
@@ -249,12 +376,30 @@ export default function App(): JSX.Element {
workspaces={workspaces}
activeWorkspace={workspaces.find((w) => w.path === state.sessionState?.workspace)}
onOpenWorkspace={openWorkspace}
plugins={entryPlugins}
activeView={activeView.view}
onOpenPlugin={handleOpenPlugin}
chatBadge={{
approvals: state.approvals.length,
error: Boolean(state.error || state.sessionError || state.retry),
streaming: state.streaming
}}
/>
<main className="relative flex min-w-0 flex-1 flex-col">
<TitleBar title={sessionTitle} panelOpen={sideOpen} onTogglePanel={toggleSide} />
<TitleBar
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
panelOpen={sideOpen}
onTogglePanel={toggleSide}
extra={<PluginStatusCapsules entries={pluginsUi.status} onOpenView={handleOpenView} />}
/>
<div className="flex min-h-0 flex-1">
<div className="relative flex min-w-0 flex-1 flex-col">
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
<div
className="relative flex min-w-0 flex-1 flex-col"
style={{ display: activeView.view === 'chat' ? undefined : 'none' }}
>
<ChatView
visible={activeView.view === 'chat'}
messages={state.messages}
tools={state.tools}
streaming={state.streaming}
@@ -273,7 +418,7 @@ export default function App(): JSX.Element {
onAbortCompaction={abortCompaction}
onRewind={handleRewind}
onFork={forkSession}
onForkAt={(entryId) => forkSessionAt(entryId, 'before')}
onForkAt={(entryId) => forkSessionAt(entryId, 'before')}
onPickSuggestion={handlePickSuggestion}
onQuoteSelection={handleQuoteSelection}
/>
@@ -296,7 +441,7 @@ export default function App(): JSX.Element {
autoCompactionEnabled={state.sessionState?.autoCompactionEnabled ?? true}
onSend={send}
onStop={stop}
onNewSession={newSession}
onNewSession={handleNewSession}
onSetModel={setModel}
onOpenSettings={() => setSettingsOpen(true)}
onPermissionMode={setPermissionMode}
@@ -311,6 +456,32 @@ export default function App(): JSX.Element {
}
/>
</div>
{/* 插件整页视图(同一时刻一个;错误边界兜底,崩溃不拖垮宿主) */}
{activePlugin && (
<div className="relative flex min-w-0 flex-1 flex-col">
<PluginErrorBoundary
pluginName={activePlugin.manifest.name}
onBack={() => setActiveView({ view: 'chat' })}
>
{ActivePluginComponent ? (
<ActivePluginComponent />
) : activePlugin.hasPage ? (
<PluginPageFrame
plugin={activePlugin}
params={activeView.params}
reloadToken={pluginReloadToken}
onOpenView={handleOpenView}
/>
) : (
<div className="flex h-full items-center justify-center text-[12.5px] text-ink-3">
插件未提供页面
</div>
)}
</PluginErrorBoundary>
</div>
)}
{sideOpen && (
<SidePanel
tabs={panelTabs}
@@ -331,10 +502,10 @@ export default function App(): JSX.Element {
open={searchOpen}
onClose={() => setSearchOpen(false)}
sessions={sessions}
onNewSession={newSession}
onNewSession={handleNewSession}
onRefreshSessions={refreshSessions}
onOpenSettings={() => setSettingsOpen(true)}
onOpenSession={openSession}
onOpenSession={handleOpenSession}
/>
<SettingsModal
open={settingsOpen}
@@ -15,7 +15,8 @@ import {
import { LuPanelLeft } from 'react-icons/lu'
import { Button } from '@/components/atoms/Button'
import { GlideMenu } from '@/components/GlideMenu'
import type { SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
import { PluginIcon } from '@/plugins/PluginPageFrame'
import type { PluginInfo, SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
* 224px ↔ 56px 宽度过渡,文案随折叠淡出;导航行与会话行共用一个
@@ -171,7 +172,11 @@ export function SidebarNav({
onOpenSettings,
workspaces,
activeWorkspace,
onOpenWorkspace
onOpenWorkspace,
plugins,
activeView,
onOpenPlugin,
chatBadge
}: {
sessionState: SessionState | null
sessions: SessionMeta[]
@@ -184,6 +189,13 @@ export function SidebarNav({
workspaces: WorkspaceMeta[]
activeWorkspace?: WorkspaceMeta
onOpenWorkspace: (path: string) => void
/** 侧栏插件入口(已排序:order 升序,核心入口之后) */
plugins: PluginInfo[]
/** 当前视图('chat' 或插件 id;插件入口高亮用) */
activeView: string
onOpenPlugin: (id: string) => void
/** 代理活动徽标(插件视图期间聊天列隐藏,会话区头提示 待审批>出错>流式) */
chatBadge?: { approvals: number; error: boolean; streaming: boolean }
}): ReactNode {
const [collapsed, setCollapsed] = useState(() => {
try {
@@ -407,7 +419,7 @@ export function SidebarNav({
</>
)}
{/* 主导航(glide hover) */}
{/* 主导航(glide hover):核心入口 + 插件入口 */}
<GlideMenu className="gap-px" highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
<button type="button" data-row onClick={onNewSession} className={navRowClass}>
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">
@@ -421,6 +433,26 @@ export function SidebarNav({
</span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">搜索</span>
</button>
{plugins.map((plugin) => {
const active = activeView === plugin.manifest.id
return (
<button
type="button"
key={plugin.manifest.id}
data-row
title={plugin.manifest.description || plugin.manifest.name}
onClick={() => onOpenPlugin(plugin.manifest.id)}
className={`${navRowClass} ${active ? 'bg-hover-2 text-ink' : ''}`}
>
<span className="flex size-5 shrink-0 items-center justify-center">
<PluginIcon svg={plugin.manifest.icon} size={18} />
</span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">
{plugin.manifest.name}
</span>
</button>
)
})}
</GlideMenu>
{/* 会话列表区 */}
@@ -439,6 +471,25 @@ export function SidebarNav({
className={`shrink-0 transition-transform duration-150 ${chatsOpen ? '' : '-rotate-90'}`}
/>
<span>会话</span>
{/* 插件视图期间聊天列隐藏:会话区头徽标提示代理活动(待审批>出错>流式) */}
{chatBadge && activeView !== 'chat' && (chatBadge.approvals > 0 || chatBadge.error || chatBadge.streaming) && (
<span
title={
chatBadge.approvals > 0
? `${chatBadge.approvals} 个待审批`
: chatBadge.error
? 'Agent 出错/重试'
: 'Agent 回复中'
}
className={`ml-0.5 inline-block size-[7px] shrink-0 rounded-full ${
chatBadge.approvals > 0
? 'animate-pulse bg-orange'
: chatBadge.error
? 'bg-red'
: 'bg-accent'
}`}
/>
)}
</button>
{/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */}
@@ -1,20 +1,23 @@
import { useEffect, useState, type JSX } from 'react'
import { useEffect, useState, type JSX, type ReactNode } from 'react'
import { LuPanelRight } from 'react-icons/lu'
/**
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
* 整条是拖拽区;左上角展示当前会话名称(无名称时显示「新会话」);
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
*/
export function TitleBar({
title,
panelOpen,
onTogglePanel
onTogglePanel,
extra
}: {
title?: string
panelOpen: boolean
onTogglePanel: () => void
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
extra?: ReactNode
}): JSX.Element {
const name = title?.trim()
const [maximized, setMaximized] = useState(false)
@@ -24,6 +27,7 @@ export function TitleBar({
return (
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]">
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
{extra}
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
<button
type="button"
+13
View File
@@ -13,6 +13,9 @@ import type {
ModelInfo,
NavigateTreeOptions,
PermissionMode,
PluginInfo,
PluginsUiState,
PluginViewChange,
ResourceDetails,
SendImage,
SessionMeta,
@@ -111,6 +114,16 @@ declare global {
browserDetectSources: () => Promise<BrowserSource[]>
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
// ---- 插件系统 ----
pluginsList: () => Promise<PluginInfo[]>
pluginsUiState: () => Promise<PluginsUiState>
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
pluginsReload: () => Promise<void>
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise<void>
onPluginsListChanged: (callback: () => void) => () => void
onPluginsUiState: (callback: (state: PluginsUiState) => void) => () => void
onPluginsView: (callback: (change: PluginViewChange) => void) => () => void
}
}
}
+7 -5
View File
@@ -32,7 +32,8 @@ export const THEME_MODES: Array<{ id: ThemeMode; label: string }> = [
{ id: 'system', label: '跟随系统' }
]
/** 应用主题预设(undefined/未知 id = 恢复默认,移除内联覆盖) */
/** 应用主题预设(undefined/未知 id = 恢复默认,移除内联覆盖)。
* 变化时派发 aso-theme-changed(插件页桥据此把新 accent 下发给 iframe 页) */
export function applyThemeColor(id: string | undefined): void {
const preset = THEME_PRESETS.find((p) => p.id === id)
const root = document.documentElement
@@ -40,11 +41,12 @@ export function applyThemeColor(id: string | undefined): void {
root.style.removeProperty('--accent')
root.style.removeProperty('--accent-ink')
root.style.removeProperty('--accent-tint')
return
} else {
root.style.setProperty('--accent', preset.accent)
root.style.setProperty('--accent-ink', preset.ink)
root.style.setProperty('--accent-tint', preset.tint)
}
root.style.setProperty('--accent', preset.accent)
root.style.setProperty('--accent-ink', preset.ink)
root.style.setProperty('--accent-tint', preset.tint)
window.dispatchEvent(new CustomEvent('aso-theme-changed'))
}
/**
@@ -0,0 +1,36 @@
import { Component, type ReactNode } from 'react'
/** 插件视图错误边界:单个插件崩溃显示占位 + 返回聊天,不拖垮整个应用 */
export class PluginErrorBoundary extends Component<
{ pluginName: string; children: ReactNode; onBack: () => void },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error): { error: Error } {
return { error }
}
componentDidCatch(error: Error): void {
console.error('[plugins] 视图渲染错误:', error)
}
render(): ReactNode {
if (this.state.error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="text-[14px] font-medium text-red">插件「{this.props.pluginName}」渲染出错</div>
<div className="max-w-md break-all text-[12px] leading-5 text-ink-3">{this.state.error.message}</div>
<button
type="button"
onClick={this.props.onBack}
className="rounded-control border border-line px-3 py-1.5 text-[12.5px] text-ink-2 transition-colors hover:bg-hover"
>
返回聊天
</button>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1,196 @@
/**
* 外置插件页宿主(iframe + 桥宿主侧)。
* - src 走 plugin://<id>/<page>?aso_t=<token>,token 变化即整页重载(热重载预览)
* - 桥安全:消息来源必须同时满足 source === iframe.contentWindow 且
* origin === 'plugin://<id>'(特权协议 standard+secure 下 iframe 有干净 origin)
* - invoke 白名单:getState / send / getSettings(其余一律拒绝)
* - 主题连续性:握手与主题变化时下发 { dark, accent }(accent 取自当前计算样式)
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { PluginInfo } from '@shared/types'
import { HARNESS_BRIDGE_VERSION, type PluginToHostMessage } from '@aso/plugin-sdk/client'
/** 插件页可调用的宿主 API 白名单(window.api 的受控子集) */
const INVOKE_ALLOWLIST = {
getState: () => window.api.getState(),
send: (text: string, images?: { mimeType: string; data: string }[]) => window.api.send(text, images),
getSettings: () => window.api.getSettings()
} as const
function currentTheme(): { dark: boolean; accent?: string } {
const root = document.documentElement
let accent = getComputedStyle(root).getPropertyValue('--accent').trim()
if (accent) accent = accent.trim()
return { dark: root.classList.contains('dark'), accent: accent || undefined }
}
/** 去除 svg 字符串里的事件属性与 script(插件签名前的最小防线) */
function sanitizeSvg(svg: string): string {
return svg
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
}
export function PluginIcon({
svg,
size = 18,
className
}: {
svg?: string
size?: number
className?: string
}): ReactNode {
if (!svg) {
return (
<span
aria-hidden
className={`block rounded-[5px] border border-line-strong ${className ?? ''}`}
style={{ width: size - 6, height: size - 6 }}
/>
)
}
return (
<span
aria-hidden
className={`flex shrink-0 items-center justify-center text-ink-2 [&_svg]:h-[1em] [&_svg]:w-[1em] ${className ?? ''}`}
style={{ fontSize: size, lineHeight: 1 }}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(svg) }}
/>
)
}
export function PluginPageFrame({
plugin,
params,
reloadToken = 0,
onOpenView
}: {
plugin: PluginInfo
/** 深链参数:握手后下发;存活期间变化即时下发 harness:deep-link */
params?: unknown
reloadToken?: number
/** 插件页发起的 openView(含 'chat' / 其他插件 id),由 App 切换视图 */
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [loaded, setLoaded] = useState(false)
const handshake = useRef(false)
const lastDeliveredParams = useRef<unknown>(undefined)
const paramsRef = useRef<unknown>(undefined)
paramsRef.current = params
const page = plugin.manifest.entry?.page || 'index.html'
const src = useMemo(
() => `plugin://${plugin.manifest.id}/${page}?aso_t=${reloadToken}`,
[plugin.manifest.id, page, reloadToken]
)
const post = (msg: unknown): void => {
iframeRef.current?.contentWindow?.postMessage(msg, `plugin://${plugin.manifest.id}`)
}
// 深链参数:握手后首次下发 + 存活期间变化即时下发
useEffect(() => {
if (!handshake.current) return
if (lastDeliveredParams.current === params) return
lastDeliveredParams.current = params
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:deep-link', params })
}, [params, plugin.manifest.id])
// 桥:接收插件页消息(source + origin 双重校验)
useEffect(() => {
const expectedOrigin = `plugin://${plugin.manifest.id}`
const onMessage = (event: MessageEvent): void => {
if (event.source !== iframeRef.current?.contentWindow) return
if (event.origin !== expectedOrigin) return
const msg = event.data as PluginToHostMessage | undefined
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string' || msg.v !== HARNESS_BRIDGE_VERSION) return
switch (msg.type) {
case 'plugin:hello': {
handshake.current = true
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:hello', contract: '0.1.0' })
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:theme', ...currentTheme() })
if (paramsRef.current !== undefined) {
lastDeliveredParams.current = paramsRef.current
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:deep-link', params: paramsRef.current })
}
break
}
case 'plugin:open-view':
onOpenView(msg.view, msg.params)
break
case 'plugin:set-status':
void window.api.pluginsSetStatus(plugin.manifest.id, msg.id, msg.item)
break
case 'plugin:invoke': {
const fn = (INVOKE_ALLOWLIST as Record<string, (...args: unknown[]) => unknown>)[msg.method]
if (!fn) {
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:invoke-result', seq: msg.seq, ok: false, error: `方法不在白名单: ${msg.method}` })
break
}
Promise.resolve()
.then(() => fn(...msg.args))
.then((value) =>
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:invoke-result', seq: msg.seq, ok: true, value: value ?? null })
)
.catch((err: unknown) =>
post({
v: HARNESS_BRIDGE_VERSION,
type: 'harness:invoke-result',
seq: msg.seq,
ok: false,
error: err instanceof Error ? err.message : String(err)
})
)
break
}
default:
break
}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [plugin.manifest.id, onOpenView])
// 主题变化(深浅色 / accent)下发
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const push = (): void => {
if (handshake.current) post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:theme', ...currentTheme() })
}
media.addEventListener('change', push)
window.addEventListener('aso-theme-changed', push)
return () => {
media.removeEventListener('change', push)
window.removeEventListener('aso-theme-changed', push)
}
}, [plugin.manifest.id])
// 换 src(热重载)时重置握手/加载态
useEffect(() => {
handshake.current = false
lastDeliveredParams.current = undefined
setLoaded(false)
}, [src])
return (
<div className="relative min-h-0 w-full flex-1">
<iframe
ref={iframeRef}
title={plugin.manifest.name}
src={src}
onLoad={() => setLoaded(true)}
// 无 allow-top-navigation(插件页不能劫持宿主导航);allow-same-origin
// 保留 plugin://<id> 干净 origin 供桥校验
sandbox="allow-scripts allow-forms allow-popups allow-same-origin"
className="h-full w-full border-0 bg-page"
/>
{!loaded && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-[12px] text-ink-3">
加载插件页…
</div>
)}
</div>
)
}
@@ -0,0 +1,51 @@
/**
* 插件状态胶囊(TitleBar B2 槽位):数据驱动渲染主进程广播的状态条目。
* 点击条目 = 执行其深链(openView)。聊天视图与插件视图下均可见。
*/
import type { ReactNode } from 'react'
import type { PluginStatusEntry } from '@shared/types'
const TONE_CLASS: Record<string, string> = {
default: 'border-line text-ink-2',
accent: 'border-accent/40 text-accent-ink bg-accent-tint',
warn: 'border-orange/40 text-orange bg-orange-tint',
danger: 'border-red/40 text-red bg-red-tint'
}
export function PluginStatusCapsules({
entries,
onOpenView
}: {
entries: PluginStatusEntry[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
if (entries.length === 0) return null
return (
<div className="ml-3 flex min-w-0 items-center gap-1.5 [-webkit-app-region:no-drag]">
{entries.slice(0, 4).map(({ pluginId, itemId, item }) => {
const tone = item.tone ?? 'default'
const clickable = Boolean(item.openView)
return (
<button
key={`${pluginId}:${itemId}`}
type="button"
title={item.text}
onClick={() => {
if (item.openView) onOpenView(item.openView.view, item.openView.params)
}}
className={`max-w-44 shrink-0 truncate rounded-full border px-2.5 py-0.5 text-[11px] leading-[18px] transition-transform ${
TONE_CLASS[tone] ?? TONE_CLASS.default
} ${clickable ? 'hover:opacity-80 active:scale-[0.97]' : 'cursor-default'}`}
>
{item.text}
</button>
)
})}
{entries.length > 4 && (
<span className="shrink-0 rounded-full border border-line px-2 py-0.5 text-[11px] leading-[18px] text-ink-3">
+{entries.length - 4}
</span>
)}
</div>
)
}
@@ -0,0 +1,7 @@
/**
* 内置插件的宿主树组件注册表(componentKey → React 组件)。
* 仅内置插件可用(外置插件 UI 只能走 iframe 页);浏览器面板(M3)在此登记。
*/
import type { ComponentType } from 'react'
export const BUILTIN_PLUGIN_COMPONENTS: Record<string, ComponentType> = {}
@@ -0,0 +1,62 @@
/**
* 渲染层插件状态 hooks:插件列表 + 内联 UI 快照(单一事实源在主进程,事件驱动同步)。
*/
import { useEffect, useState } from 'react'
import type { PluginInfo, PluginsUiState } from '@shared/types'
const EMPTY_UI: PluginsUiState = { status: [], toolbar: [] }
export function usePluginList(): PluginInfo[] {
const [plugins, setPlugins] = useState<PluginInfo[]>([])
useEffect(() => {
let alive = true
const refresh = (): void => {
window.api
.pluginsList()
.then((list) => {
if (alive) setPlugins(list)
})
.catch(() => undefined)
}
refresh()
const off = window.api.onPluginsListChanged(refresh)
return () => {
alive = false
off()
}
}, [])
return plugins
}
export function usePluginsUiState(): PluginsUiState {
const [state, setState] = useState<PluginsUiState>(EMPTY_UI)
useEffect(() => {
let alive = true
const refresh = (): void => {
window.api
.pluginsUiState()
.then((snapshot) => {
if (alive) setState(snapshot)
})
.catch(() => undefined)
}
refresh()
const off = window.api.onPluginsUiState(setState)
return () => {
alive = false
off()
}
}, [])
return state
}
/** 仅返回已启用且有侧栏入口的插件(入口排序:order 升序,其次 id) */
export function sidebarPlugins(plugins: PluginInfo[]): PluginInfo[] {
return plugins
.filter((p) => p.enabled && (p.hasComponent || p.hasPage))
.sort(
(a, b) =>
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) ||
a.manifest.id.localeCompare(b.manifest.id)
)
}