feat: 动态插件系统 M0-M3(两层能力模型 + pi 原生契约 + Obsidian 式 SDK)
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
import { app, BrowserWindow, ipcMain, Menu, nativeTheme, shell } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, Menu, nativeTheme, protocol, shell } from 'electron'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpc } from './ipc'
|
||||
import { getPluginHost } from './plugins/plugin-host'
|
||||
import { getBrowserController } from './browser/browser-controller'
|
||||
|
||||
process.env.APP_ROOT = join(__dirname, '..')
|
||||
|
||||
// 外置插件页的特权协议(必须在 app ready 之前注册):
|
||||
// - plugin://<id>/<file> → userData/plugins/<id>/ 内文件(宿主做路径穿越防护)
|
||||
// - peer://assets/tokens.css → 主题 token 样式表(插件页 <link> 引用,保证深浅色/accent 连续)
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'plugin', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
|
||||
{ scheme: 'peer', privileges: { standard: true, secure: true, supportFetchAPI: true } }
|
||||
])
|
||||
|
||||
export const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL
|
||||
export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
|
||||
|
||||
@@ -134,6 +143,13 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.setAppUserModelId('com.inkreach.agent')
|
||||
}
|
||||
Menu.setApplicationMenu(null)
|
||||
|
||||
// 插件宿主:扫描/装载 + 协议映射 + 插件启停后重建 agent 运行时
|
||||
const pluginHost = getPluginHost()
|
||||
pluginHost.init()
|
||||
pluginHost.registerProtocols()
|
||||
pluginHost.setRuntimeRebuilder(() => agentService.rebuildRuntime())
|
||||
|
||||
void createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { userInfo } from 'node:os'
|
||||
import { AgentService } from './pi/agent-service'
|
||||
import { getPluginHost } from './plugins/plugin-host'
|
||||
import { getBrowserController } from './browser/browser-controller'
|
||||
import { detectBrowserSources, importBrowserProfile } from './browser/browser-profile'
|
||||
|
||||
@@ -380,5 +381,29 @@ export function registerIpc(): AgentService {
|
||||
return browserPass(() => importBrowserProfile(sourceId, profileDir))
|
||||
})
|
||||
|
||||
// ---- 插件系统 ----
|
||||
void app.whenReady().then(() => {
|
||||
// 插件启停/重载会重建 agent 运行时(extensionFactories 重跑)
|
||||
getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime())
|
||||
})
|
||||
|
||||
ipcMain.handle('plugins:list', () => getPluginHost().list())
|
||||
ipcMain.handle('plugins:uiState', () => getPluginHost().uiState())
|
||||
ipcMain.handle('plugins:setEnabled', (_e, id: unknown, enabled: unknown) => {
|
||||
if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle')
|
||||
return getPluginHost().setEnabled(id, enabled)
|
||||
})
|
||||
ipcMain.handle('plugins:reload', () => getPluginHost().reload())
|
||||
ipcMain.handle('plugins:openView', (_e, view: unknown, params: unknown) => {
|
||||
if (typeof view !== 'string' || !view) throw new Error('invalid view')
|
||||
getPluginHost().openView(view, params)
|
||||
})
|
||||
// 插件页(iframe)经渲染层桥转发来的状态胶囊写请求;pluginId 键控防越权
|
||||
ipcMain.handle('plugins:setStatus', (_e, pluginId: unknown, itemId: unknown, item: unknown) => {
|
||||
if (typeof pluginId !== 'string' || typeof itemId !== 'string') throw new Error('invalid status item')
|
||||
getPluginHost().setStatusItem(pluginId, itemId, (item ?? null) as never)
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
type SessionInfo
|
||||
} from '@earendil-works/pi-coding-agent'
|
||||
import { ApprovalService, createApprovalExtension } from './approval'
|
||||
import { browserTools } from '../tools/browser-tools'
|
||||
import { getPluginHost } from '../plugins/plugin-host'
|
||||
import { browserToolNames } from '../tools/browser-tools'
|
||||
import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
@@ -47,8 +48,9 @@ import type {
|
||||
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
||||
const DELTA_FLUSH_MS = 40
|
||||
|
||||
/** SDK 注册的自定义工具名(用量卡片中与内置「系统工具」分开统计) */
|
||||
const CUSTOM_TOOL_NAMES = new Set<string>(browserTools.map((tool) => tool.name))
|
||||
/** SDK 注册的自定义工具名(用量卡片中与内置「系统工具」分开统计)。
|
||||
* browserTools 已迁入浏览器插件(pi.registerTool 注册),这里同步其名单。 */
|
||||
const CUSTOM_TOOL_NAMES = new Set<string>(browserToolNames)
|
||||
|
||||
/** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */
|
||||
const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = new Set(['bash', 'write', 'edit'])
|
||||
@@ -270,7 +272,9 @@ export class AgentService {
|
||||
agentDir: targetAgentDir,
|
||||
resourceLoaderOptions: {
|
||||
extensionFactories: [
|
||||
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode)
|
||||
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode),
|
||||
// 插件聚合 extension:全部已启用插件在同一真实 ExtensionAPI 上下文里激活
|
||||
getPluginHost().createAggregatedExtension()
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -290,8 +294,9 @@ export class AgentService {
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
customTools: browserTools
|
||||
sessionStartEvent
|
||||
// 业务工具(browserTools 等)由插件经 pi.registerTool 注册(聚合 extension),
|
||||
// 此处不再传 customTools
|
||||
})
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
@@ -1125,7 +1130,8 @@ export class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
private async rebuildRuntime(): Promise<void> {
|
||||
/** 重建运行时(插件重载/启停后由 PluginHost 调用,extensionFactories 重跑) */
|
||||
async rebuildRuntime(): Promise<void> {
|
||||
const old = this.runtime
|
||||
this.runtime = null
|
||||
this.initPromise = null
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 浏览器插件(内置,组件级:UI 仍由宿主树 BrowserPanel 承载,面板 tab 见 App.tsx)。
|
||||
* 主进程侧:browserTools 经 pi.registerTool 注册(原生路径,提示词注入/审批拦截
|
||||
* 与 pi 生态一致),并示范内联 UI——受管浏览器打开时推状态胶囊。
|
||||
*/
|
||||
import { getBrowserController } from '../../browser/browser-controller'
|
||||
import { browserTools } from '../../tools/browser-tools'
|
||||
import type { BuiltinPlugin } from './index'
|
||||
|
||||
export const browserPlugin: BuiltinPlugin = {
|
||||
manifest: {
|
||||
id: 'browser',
|
||||
name: '浏览器',
|
||||
version: '0.1.0',
|
||||
minContract: '0.1.0',
|
||||
panel: true,
|
||||
order: 10,
|
||||
description: '受管浏览器(CDP 驱动,Agent 可操作,右侧面板查看)'
|
||||
},
|
||||
main: (api) => {
|
||||
for (const tool of browserTools) {
|
||||
api.pi.registerTool(tool)
|
||||
}
|
||||
|
||||
const pushStatus = (): void => {
|
||||
const state = getBrowserController().getState()
|
||||
if (!state.open || state.tabs.length === 0) {
|
||||
api.ui.removeStatusItem('tabs')
|
||||
return
|
||||
}
|
||||
const active = state.tabs.find((t) => t.tabId === state.activeTabId)
|
||||
const title = active?.title?.trim()
|
||||
api.ui.setStatusItem('tabs', {
|
||||
text: `浏览器 ${state.tabs.length} 页${title ? ` · ${title.slice(0, 12)}` : ''}`,
|
||||
tone: 'default'
|
||||
})
|
||||
}
|
||||
|
||||
const off = getBrowserController().onState(pushStatus)
|
||||
pushStatus()
|
||||
return () => {
|
||||
off()
|
||||
api.ui.removeStatusItem('tabs')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 内置插件注册表(编译进宿主 bundle 的"特权实现"):
|
||||
* - main:第一方工厂(不走动态 import;pi ExtensionAPI 语义与外置一致)
|
||||
* - component:渲染层组件 key(plugins/builtin-components.ts 里登记的宿主树组件;
|
||||
* 外置插件没有这项能力,遮蔽组件级内置需 manifest.replacesNative 声明)
|
||||
* 浏览器面板(M3)在此登记。
|
||||
*/
|
||||
import type { HarnessPluginFactory, PluginManifest } from '@aso/plugin-sdk'
|
||||
import { browserPlugin } from './browser'
|
||||
|
||||
export interface BuiltinPlugin {
|
||||
manifest: PluginManifest
|
||||
/** 主进程工厂(注册 pi 工具/事件、内联 UI) */
|
||||
main?: HarnessPluginFactory
|
||||
/** 渲染层宿主树组件 key(renderer/src/plugins/builtin-components.ts) */
|
||||
component?: string
|
||||
}
|
||||
|
||||
export const BUILTIN_PLUGINS: BuiltinPlugin[] = [browserPlugin]
|
||||
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* 插件宿主(主进程):
|
||||
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
|
||||
* - 外置插件:userData/plugins/<id>/manifest.json + 可选 main.mjs(动态 import)
|
||||
* - 同 id 高 semver 胜、平级外置胜;外置遮蔽组件级内置需 manifest.replacesNative
|
||||
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
|
||||
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
|
||||
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
|
||||
* - plugin:// 协议把插件目录映射给 iframe 页(含路径穿越防护)
|
||||
*
|
||||
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
||||
* ed25519 验签 + 开发者模式豁免)。
|
||||
*/
|
||||
import { app, BrowserWindow, net, protocol } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
|
||||
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
||||
|
||||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||
export const HARNESS_CONTRACT_VERSION = '0.1.0'
|
||||
|
||||
interface ResolvedPlugin {
|
||||
manifest: PluginManifest
|
||||
source: 'builtin' | 'external'
|
||||
/** 外置插件根目录(plugin:// 映射根) */
|
||||
dir?: string
|
||||
/** 内置:第一方工厂 / 渲染层组件 key */
|
||||
builtinFactory?: HarnessPluginFactory
|
||||
componentKey?: string
|
||||
/** 外置 main.mjs 动态导入的工厂(reload 时替换) */
|
||||
externalFactory?: HarnessPluginFactory
|
||||
/** 装载/激活错误(管理页展示;不阻塞其他插件) */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 最小 semver(x.y.z 数值三元组;忽略预发布段) */
|
||||
function parseSemver(v: string): [number, number, number] | null {
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim())
|
||||
if (!m) return null
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])]
|
||||
}
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const pa = parseSemver(a)
|
||||
const pb = parseSemver(b)
|
||||
if (!pa || !pb) return 0
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
class PluginHost {
|
||||
private resolved = new Map<string, ResolvedPlugin>()
|
||||
private disabled = new Set<string>()
|
||||
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
|
||||
private rebuildRuntime: (() => Promise<void>) | null = null
|
||||
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
||||
private statusItems = new Map<string, Map<string, StatusItem>>()
|
||||
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
|
||||
/** 插件 KV 存储(pluginId → key → value) */
|
||||
private storage = new Map<string, Map<string, unknown>>()
|
||||
private storageSaveTimer: NodeJS.Timeout | null = null
|
||||
|
||||
private get externalRoot(): string {
|
||||
return join(app.getPath('userData'), 'plugins')
|
||||
}
|
||||
|
||||
private get settingsFile(): string {
|
||||
return join(app.getPath('userData'), 'aso-plugin-settings.json')
|
||||
}
|
||||
|
||||
private get storageFile(): string {
|
||||
return join(app.getPath('userData'), 'aso-plugin-storage.json')
|
||||
}
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
init(): void {
|
||||
try {
|
||||
mkdirSync(this.externalRoot, { recursive: true })
|
||||
} catch {
|
||||
// 已存在或创建失败都继续(扫描时再报)
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown }
|
||||
if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string'))
|
||||
} catch {
|
||||
// 无设置文件 = 全部启用
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown>
|
||||
for (const [pid, kv] of Object.entries(raw)) {
|
||||
if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
|
||||
this.storage.set(pid, new Map(Object.entries(kv as Record<string, unknown>)))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 无存储文件 = 空
|
||||
}
|
||||
void this.scan()
|
||||
}
|
||||
|
||||
setRuntimeRebuilder(fn: () => Promise<void>): void {
|
||||
this.rebuildRuntime = fn
|
||||
}
|
||||
|
||||
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建) */
|
||||
async scan(): Promise<void> {
|
||||
const byId = new Map<string, ResolvedPlugin>()
|
||||
|
||||
for (const builtin of BUILTIN_PLUGINS) {
|
||||
byId.set(builtin.manifest.id, {
|
||||
manifest: builtin.manifest,
|
||||
source: 'builtin',
|
||||
builtinFactory: builtin.main,
|
||||
componentKey: builtin.component
|
||||
})
|
||||
}
|
||||
|
||||
if (existsSync(this.externalRoot)) {
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = readdirSync(this.externalRoot, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = []
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const dir = join(this.externalRoot, entry.name)
|
||||
const manifest = this.readManifest(dir)
|
||||
if (!manifest) continue
|
||||
const existing = byId.get(manifest.id)
|
||||
if (existing) {
|
||||
const cmp = compareSemver(manifest.version, existing.manifest.version)
|
||||
// 外置仅在严格更高版本时遮蔽内置;平级外置胜仅适用于同为外置的场景(此处 existing 必为内置)
|
||||
if (cmp <= 0) continue
|
||||
// 组件级内置只能被显式声明降级替代的外置遮蔽
|
||||
if (existing.componentKey && !manifest.replacesNative) {
|
||||
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}`
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
|
||||
byId.set(manifest.id, {
|
||||
manifest,
|
||||
source: 'external',
|
||||
dir,
|
||||
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
byId.set(manifest.id, { manifest, source: 'external', dir })
|
||||
}
|
||||
}
|
||||
|
||||
this.resolved = byId
|
||||
await this.loadExternalMains()
|
||||
this.send('plugins:listChanged', null)
|
||||
}
|
||||
|
||||
private readManifest(dir: string): PluginManifest | null {
|
||||
const file = join(dir, 'manifest.json')
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(file, 'utf8')) as Partial<PluginManifest>
|
||||
if (typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.version !== 'string') return null
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(raw.id)) return null
|
||||
if (!parseSemver(raw.version) || !parseSemver(raw.minContract ?? '0.0.0')) return null
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
version: raw.version,
|
||||
minContract: raw.minContract ?? '0.0.0',
|
||||
description: typeof raw.description === 'string' ? raw.description : undefined,
|
||||
icon: typeof raw.icon === 'string' ? raw.icon : undefined,
|
||||
order: typeof raw.order === 'number' ? raw.order : undefined,
|
||||
panel: raw.panel === true,
|
||||
entry:
|
||||
raw.entry && typeof raw.entry === 'object'
|
||||
? {
|
||||
page: typeof raw.entry.page === 'string' ? raw.entry.page : undefined,
|
||||
main: typeof raw.entry.main === 'string' ? raw.entry.main : undefined
|
||||
}
|
||||
: undefined,
|
||||
replacesNative: raw.replacesNative === true,
|
||||
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 动态导入外置 main.mjs(cache-bust 保证重载拿到新代码) */
|
||||
private async loadExternalMains(): Promise<void> {
|
||||
for (const plugin of this.resolved.values()) {
|
||||
if (plugin.source !== 'external' || !plugin.manifest.entry?.main || plugin.error) continue
|
||||
const file = resolve(plugin.dir!, plugin.manifest.entry.main)
|
||||
try {
|
||||
const url = pathToFileURL(file).href + `?t=${Date.now()}`
|
||||
const mod = (await import(url)) as { default?: unknown }
|
||||
if (typeof mod.default !== 'function') {
|
||||
plugin.error = 'main.mjs 默认导出必须是插件工厂函数'
|
||||
continue
|
||||
}
|
||||
plugin.externalFactory = mod.default as HarnessPluginFactory
|
||||
plugin.error = undefined
|
||||
} catch (err) {
|
||||
plugin.externalFactory = undefined
|
||||
plugin.error = `main 加载失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */
|
||||
async reload(): Promise<void> {
|
||||
this.statusItems.clear()
|
||||
this.toolbarButtons.clear()
|
||||
await this.scan()
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
if (this.rebuildRuntime) await this.rebuildRuntime()
|
||||
}
|
||||
|
||||
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
if (enabled) this.disabled.delete(id)
|
||||
else this.disabled.add(id)
|
||||
try {
|
||||
writeFileSync(this.settingsFile, JSON.stringify({ disabled: [...this.disabled] }, null, 2), 'utf8')
|
||||
} catch (err) {
|
||||
console.error('[plugins] save settings failed:', err)
|
||||
}
|
||||
await this.reload()
|
||||
}
|
||||
|
||||
// ---- 查询 ----
|
||||
|
||||
isEnabled(id: string): boolean {
|
||||
return !this.disabled.has(id)
|
||||
}
|
||||
|
||||
list(): PluginInfo[] {
|
||||
return [...this.resolved.values()]
|
||||
.map((p) => ({
|
||||
manifest: p.manifest,
|
||||
source: p.source,
|
||||
enabled: this.isEnabled(p.manifest.id),
|
||||
hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
|
||||
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
|
||||
hasComponent: Boolean(p.componentKey),
|
||||
error: p.error
|
||||
}))
|
||||
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
|
||||
}
|
||||
|
||||
getComponentKey(id: string): string | undefined {
|
||||
const p = this.resolved.get(id)
|
||||
return p?.componentKey
|
||||
}
|
||||
|
||||
uiState(): PluginsUiState {
|
||||
const status: PluginStatusEntry[] = []
|
||||
for (const [pluginId, items] of this.statusItems) {
|
||||
for (const [itemId, item] of items) status.push({ pluginId, itemId, item })
|
||||
}
|
||||
const toolbar: PluginToolbarEntry[] = []
|
||||
for (const [pluginId, buttons] of this.toolbarButtons) {
|
||||
for (const [buttonId, button] of buttons) toolbar.push({ pluginId, buttonId, button })
|
||||
}
|
||||
return { status, toolbar }
|
||||
}
|
||||
|
||||
// ---- 内联 UI(数据驱动;单一事实源在主进程) ----
|
||||
|
||||
setStatusItem(pluginId: string, itemId: string, item: StatusItem | null): void {
|
||||
if (!this.resolved.has(pluginId)) return
|
||||
let items = this.statusItems.get(pluginId)
|
||||
if (!items) {
|
||||
items = new Map()
|
||||
this.statusItems.set(pluginId, items)
|
||||
}
|
||||
if (item) items.set(itemId, item)
|
||||
else items.delete(itemId)
|
||||
if (items.size === 0) this.statusItems.delete(pluginId)
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
}
|
||||
|
||||
registerToolbarButton(pluginId: string, button: ToolbarButtonDef): void {
|
||||
if (!this.resolved.has(pluginId)) return
|
||||
let buttons = this.toolbarButtons.get(pluginId)
|
||||
if (!buttons) {
|
||||
buttons = new Map()
|
||||
this.toolbarButtons.set(pluginId, buttons)
|
||||
}
|
||||
buttons.set(button.id, button)
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
}
|
||||
|
||||
removeToolbarButton(pluginId: string, buttonId: string): void {
|
||||
const buttons = this.toolbarButtons.get(pluginId)
|
||||
if (!buttons) return
|
||||
buttons.delete(buttonId)
|
||||
if (buttons.size === 0) this.toolbarButtons.delete(pluginId)
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
}
|
||||
|
||||
/** 清空某插件的全部内联 UI(禁用/重载路径) */
|
||||
private clearPluginUi(pluginId: string): void {
|
||||
this.statusItems.delete(pluginId)
|
||||
this.toolbarButtons.delete(pluginId)
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
}
|
||||
|
||||
// ---- 深链 ----
|
||||
|
||||
openView(view: string, params?: unknown): void {
|
||||
this.send('plugins:view', { view, params })
|
||||
}
|
||||
|
||||
// ---- 插件 KV 存储 ----
|
||||
|
||||
storageFor(pluginId: string) {
|
||||
return {
|
||||
get: <T>(key: string): T | undefined => {
|
||||
const kv = this.storage.get(pluginId)
|
||||
const v = kv?.get(key)
|
||||
return v === undefined ? undefined : (structuredClone(v) as T)
|
||||
},
|
||||
set: (key: string, value: unknown): void => {
|
||||
let kv = this.storage.get(pluginId)
|
||||
if (!kv) {
|
||||
kv = new Map()
|
||||
this.storage.set(pluginId, kv)
|
||||
}
|
||||
kv.set(key, structuredClone(value))
|
||||
this.scheduleStorageSave()
|
||||
},
|
||||
delete: (key: string): void => {
|
||||
this.storage.get(pluginId)?.delete(key)
|
||||
this.scheduleStorageSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleStorageSave(): void {
|
||||
if (this.storageSaveTimer) return
|
||||
this.storageSaveTimer = setTimeout(() => {
|
||||
this.storageSaveTimer = null
|
||||
const doc: Record<string, Record<string, unknown>> = {}
|
||||
for (const [pid, kv] of this.storage) doc[pid] = Object.fromEntries(kv)
|
||||
try {
|
||||
writeFileSync(this.storageFile, JSON.stringify(doc, null, 2), 'utf8')
|
||||
} catch (err) {
|
||||
console.error('[plugins] save storage failed:', err)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ---- 聚合 pi extension ----
|
||||
|
||||
/** 挂进 AgentService 的 extensionFactories;工厂内激活全部已启用插件 */
|
||||
createAggregatedExtension(): InlineExtension {
|
||||
return {
|
||||
name: 'aso-plugins',
|
||||
factory: (pi: ExtensionAPI) => {
|
||||
const disposers: Array<() => void> = []
|
||||
for (const plugin of this.resolved.values()) {
|
||||
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue
|
||||
const factory = plugin.builtinFactory ?? plugin.externalFactory
|
||||
if (!factory) continue
|
||||
try {
|
||||
const result = factory(this.buildApi(plugin, pi))
|
||||
if (typeof result === 'function') {
|
||||
disposers.push(result)
|
||||
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||
void (result as Promise<void | (() => void)>).then((d) => {
|
||||
if (typeof d === 'function') disposers.push(d)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
this.clearPluginUi(plugin.manifest.id)
|
||||
this.send('plugins:listChanged', null)
|
||||
}
|
||||
}
|
||||
pi.on('session_shutdown', () => {
|
||||
for (const dispose of disposers) {
|
||||
try {
|
||||
dispose()
|
||||
} catch (err) {
|
||||
console.error('[plugins] dispose error:', err)
|
||||
}
|
||||
}
|
||||
disposers.length = 0
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) {
|
||||
const id = plugin.manifest.id
|
||||
return {
|
||||
pi,
|
||||
manifest: plugin.manifest,
|
||||
storage: this.storageFor(id),
|
||||
ui: {
|
||||
openView: (view: string, params?: unknown) => this.openView(view, params),
|
||||
setStatusItem: (itemId: string, item: StatusItem) => this.setStatusItem(id, itemId, item),
|
||||
removeStatusItem: (itemId: string) => this.setStatusItem(id, itemId, null),
|
||||
registerToolbarButton: (button: ToolbarButtonDef) => this.registerToolbarButton(id, button),
|
||||
removeToolbarButton: (buttonId: string) => this.removeToolbarButton(id, buttonId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- plugin:// 协议 ----
|
||||
|
||||
/** plugin://<id>/<rel> → 插件目录内文件;路径必须落在该插件根内(防穿越) */
|
||||
resolvePluginFile(id: string, rel: string): string | null {
|
||||
const plugin = this.resolved.get(id)
|
||||
if (!plugin || plugin.source !== 'external' || !plugin.dir) return null
|
||||
const root = resolve(plugin.dir)
|
||||
const target = resolve(root, rel)
|
||||
if (target !== root && !target.startsWith(root + sep)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
registerProtocols(): void {
|
||||
protocol.handle('plugin', (request) => this.serveFile(() => {
|
||||
const url = new URL(request.url)
|
||||
const id = url.hostname
|
||||
const rel = decodeURIComponent(url.pathname).replace(/^\/+/, '')
|
||||
const file = this.resolvePluginFile(id, rel)
|
||||
return file ?? ''
|
||||
}))
|
||||
|
||||
// peer:// 只暴露固定白名单资产(tokens.css 主题变量表,插件页 <link> 引用)
|
||||
protocol.handle('peer', (request) => this.serveFile(() => {
|
||||
const url = new URL(request.url)
|
||||
const asset = `${url.hostname}${url.pathname}`.replace(/\/+$/, '')
|
||||
if (asset !== 'assets/tokens.css') return ''
|
||||
return this.tokensCssPath()
|
||||
}))
|
||||
}
|
||||
|
||||
private tokensCssPath(): string {
|
||||
// dev:源文件;prod:构建期拷贝到 out/renderer/tokens.css(vite closeBundle 插件)
|
||||
const src = resolve(app.getAppPath(), 'src', 'renderer', 'src', 'styles', 'beautifului.css')
|
||||
if (existsSync(src)) return src
|
||||
return resolve(app.getAppPath(), 'out', 'renderer', 'tokens.css')
|
||||
}
|
||||
|
||||
private async serveFile(resolvePath: () => string): Promise<Response> {
|
||||
let file = ''
|
||||
try {
|
||||
file = resolvePath()
|
||||
} catch {
|
||||
file = ''
|
||||
}
|
||||
if (!file || !existsSync(file)) return new Response('not found', { status: 404 })
|
||||
try {
|
||||
const res = await net.fetch(pathToFileURL(file).toString())
|
||||
// no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'content-type': res.headers.get('content-type') ?? 'application/octet-stream',
|
||||
'cache-control': 'no-store'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[plugins] serve failed:', file, err)
|
||||
return new Response('serve error', { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 渲染层广播 ----
|
||||
|
||||
private send(channel: string, payload: unknown): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(channel, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let host: PluginHost | null = null
|
||||
|
||||
export function getPluginHost(): PluginHost {
|
||||
if (!host) host = new PluginHost()
|
||||
return host
|
||||
}
|
||||
|
||||
export type { BuiltinPlugin }
|
||||
@@ -13,6 +13,9 @@ import type {
|
||||
ModelCatalog,
|
||||
NavigateTreeOptions,
|
||||
PermissionMode,
|
||||
PluginInfo,
|
||||
PluginsUiState,
|
||||
PluginViewChange,
|
||||
ResourceDetails,
|
||||
SendImage,
|
||||
SessionMeta,
|
||||
@@ -129,6 +132,39 @@ const api = {
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser:event', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 插件系统 ----
|
||||
pluginsList: (): Promise<PluginInfo[]> => ipcRenderer.invoke('plugins:list'),
|
||||
pluginsUiState: (): Promise<PluginsUiState> => ipcRenderer.invoke('plugins:uiState'),
|
||||
pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('plugins:setEnabled', id, enabled),
|
||||
pluginsReload: (): Promise<void> => ipcRenderer.invoke('plugins:reload'),
|
||||
pluginsOpenView: (view: string, params?: unknown): Promise<void> =>
|
||||
ipcRenderer.invoke('plugins:openView', view, params),
|
||||
/** 插件页桥转发(渲染层桥校验来源后调用;pluginId 键控写主进程单一事实源) */
|
||||
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown): Promise<void> =>
|
||||
ipcRenderer.invoke('plugins:setStatus', pluginId, itemId, item),
|
||||
onPluginsListChanged: (callback: () => void): (() => void) => {
|
||||
const listener = (): void => callback()
|
||||
ipcRenderer.on('plugins:listChanged', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('plugins:listChanged', listener)
|
||||
}
|
||||
},
|
||||
onPluginsUiState: (callback: (state: PluginsUiState) => void): (() => void) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, state: PluginsUiState): void => callback(state)
|
||||
ipcRenderer.on('plugins:uiState', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('plugins:uiState', listener)
|
||||
}
|
||||
},
|
||||
onPluginsView: (callback: (change: PluginViewChange) => void): (() => void) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, change: PluginViewChange): void => callback(change)
|
||||
ipcRenderer.on('plugins:view', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('plugins:view', listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -350,3 +350,48 @@ export type AgentUiEvent =
|
||||
/** 压缩后的最新消息列表(含 compactionSummary 通知行,替代整次 session_switched) */
|
||||
messages: unknown[]
|
||||
}
|
||||
|
||||
// ===== 插件系统(契约类型来自 @aso/plugin-sdk) =====
|
||||
|
||||
import type { PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
||||
|
||||
/** 插件条目(管理页/侧栏入口数据源) */
|
||||
export interface PluginInfo {
|
||||
manifest: PluginManifest
|
||||
source: 'builtin' | 'external'
|
||||
enabled: boolean
|
||||
/** 有主进程工厂(pi 工具/事件/内联 UI 注册能力) */
|
||||
hasMain: boolean
|
||||
/** 有 iframe 插件页 */
|
||||
hasPage: boolean
|
||||
/** 有宿主树组件(仅内置;外置遮蔽需 replacesNative 声明) */
|
||||
hasComponent: boolean
|
||||
/** 装载/激活错误(不影响其他插件) */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 状态胶囊条目(主进程单一事实源广播) */
|
||||
export interface PluginStatusEntry {
|
||||
pluginId: string
|
||||
itemId: string
|
||||
item: StatusItem
|
||||
}
|
||||
|
||||
/** 工具栏按钮条目 */
|
||||
export interface PluginToolbarEntry {
|
||||
pluginId: string
|
||||
buttonId: string
|
||||
button: ToolbarButtonDef
|
||||
}
|
||||
|
||||
/** 内联 UI 快照(plugins:uiState 事件与查询共用) */
|
||||
export interface PluginsUiState {
|
||||
status: PluginStatusEntry[]
|
||||
toolbar: PluginToolbarEntry[]
|
||||
}
|
||||
|
||||
/** 深链指令(主进程 openView / 插件页桥 → 渲染层切换视图) */
|
||||
export interface PluginViewChange {
|
||||
view: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user