feat: 动态插件系统 M0-M3(两层能力模型 + pi 原生契约 + Obsidian 式 SDK)
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'"
|
||||
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>
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user