feat: 动态插件系统 M0-M3(两层能力模型 + pi 原生契约 + Obsidian 式 SDK)

This commit is contained in:
Rick Ye
2026-09-10 02:15:55 +08:00
parent 2b69a9aa13
commit 367f3f5c5b
37 changed files with 2009 additions and 5773 deletions
+26 -2
View File
@@ -1,7 +1,27 @@
import { copyFileSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import type { Plugin } from 'vite'
/** 构建期把主题 token 样式表拷到 out/renderer/tokens.css:
* 生产环境 src/ 不进包,插件页经 peer://assets/tokens.css 引用它保证深浅色/accent 连续 */
function copyTokensCss(): Plugin {
return {
name: 'aso-copy-tokens-css',
closeBundle() {
const src = resolve(__dirname, 'src/renderer/src/styles/beautifului.css')
const outDir = resolve(__dirname, 'out/renderer')
try {
mkdirSync(outDir, { recursive: true })
copyFileSync(src, resolve(outDir, 'tokens.css'))
} catch (err) {
this.warn(`tokens.css 拷贝失败: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
}
export default defineConfig({
main: {
@@ -49,8 +69,12 @@ export default defineConfig({
// 默认 5173 常被圈进保留段导致 EACCES;固定用 5323 避开(若日后再撞,
// 可换端口,或管理员执行 net stop winnat && net start winnat 后
// netsh int ipv4 add excludedportrange protocol=tcp startport=<端口> numberofports=1 永久占用)
server: { port: 5323 },
plugins: [react(), tailwindcss()],
server: {
port: 5323,
// 允许 dev server 读取工作区源码包(@aso/plugin-sdk 从 packages/ 链接进来)
fs: { allow: [resolve(__dirname, '../..')] }
},
plugins: [react(), tailwindcss(), copyTokensCss()],
resolve: {
alias: {
'@': resolve(__dirname, 'src/renderer/src'),
-5624
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -38,6 +38,7 @@
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@aso/plugin-sdk": "workspace:*",
"@tailwindcss/vite": "4.3.3",
"@types/node": "26.4.1",
"@types/react": "19.2.18",
+17 -1
View File
@@ -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', () => {
+25
View File
@@ -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
}
+13 -7
View File
@@ -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 }
+36
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:"
/>
<title>Inkreach Agent</title>
</head>
+201 -30
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { JSX, ComponentType } from 'react'
import { LuGlobe } from 'react-icons/lu'
import { useAgent } from './hooks/useAgent'
import { useBrowser } from './hooks/useBrowser'
@@ -12,7 +12,13 @@ import { SettingsModal } from './components/SettingsModal'
import { Search } from './components/Search'
import { BrowserPanel } from './components/browser/BrowserPanel'
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
import { applyThemeColor, watchColorScheme } from './lib/theme'
import type { PluginInfo } from '@shared/types'
// ---- 侧边面板(TabPanel):开合 / 标签 / 宽度,跨启动持久化 ----
@@ -29,17 +35,14 @@ interface SideState {
active: string | null
}
/** 面板标签注册表:新标签在这里登记即可进入 TabPanel 通用机制 */
const PANEL_TAB_META: Record<string, { title: string; icon: PanelTabIcon }> = {
browser: { title: '浏览器', icon: LuGlobe }
}
/** 面板标签元数据:browser 为宿主内置;插件标签由 manifest(panel:true)动态注册。
* loadSideState 只做形状校验,非法 id(已卸载插件等)在装配 panelTabs 时跳过。 */
function loadSideState(): SideState {
try {
const raw = localStorage.getItem(SIDE_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<SideState>
const tabs = Array.isArray(parsed.tabs) ? parsed.tabs.filter((t) => t in PANEL_TAB_META) : []
const tabs = Array.isArray(parsed.tabs) ? parsed.tabs.filter((t) => typeof t === 'string') : []
return {
open: Boolean(parsed.open),
width:
@@ -93,6 +96,83 @@ export default function App(): JSX.Element {
const [sideOverlay, setSideOverlay] = useState(false)
const { open: sideOpen, tabs: sideTabIds, active: sideActive, width: sideWidth } = side
// ---- 插件系统:视图切换('chat' | 插件 id)+ 深链参数 ----
const plugins = usePluginList()
const pluginsUi = usePluginsUiState()
const [activeView, setActiveView] = useState<{ view: string; params?: unknown }>({ view: 'chat' })
const activeViewRef = useRef(activeView)
activeViewRef.current = activeView
const pluginById = useMemo(() => new Map(plugins.map((p) => [p.manifest.id, p])), [plugins])
const pluginByIdRef = useRef(pluginById)
pluginByIdRef.current = pluginById
const entryPlugins = useMemo(() => sidebarPlugins(plugins), [plugins])
const panelPlugins = useMemo(
() => plugins.filter((p) => p.enabled && p.manifest.panel && p.hasPage),
[plugins]
)
/** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */
const [pluginReloadToken, setPluginReloadToken] = useState(0)
/** 打开视图('chat' 或插件 id,携深链参数);插件声明 panel 时联动展开其面板标签 */
const handleOpenView = useCallback((view: string, params?: unknown) => {
if (view === 'chat') {
setActiveView({ view: 'chat' })
return
}
const plugin = pluginByIdRef.current.get(view)
if (!plugin || !plugin.enabled || (!plugin.hasComponent && !plugin.hasPage)) return
setActiveView({ view, params })
if (plugin.manifest.panel) {
setSide((s) => ({
...s,
open: true,
tabs: s.tabs.includes(view) ? s.tabs : [...s.tabs, view],
active: view
}))
}
}, [])
// 主进程侧 openView(插件 main 工厂 / 深链按钮)→ 渲染层切视图
useEffect(() => window.api.onPluginsView((change) => handleOpenView(change.view, change.params)), [handleOpenView])
// 插件列表变化(扫描/重载/启停)→ 刷新外置插件页(iframe 换 src 整页重载)
useEffect(
() =>
window.api.onPluginsListChanged(() => {
setPluginReloadToken((t) => t + 1)
}),
[]
)
// 插件列表变化时校正当前视图(插件被禁用/卸载后回落聊天)
useEffect(() => {
setActiveView((current) => {
if (current.view === 'chat') return current
const plugin = pluginById.get(current.view)
return plugin && plugin.enabled ? current : { view: 'chat' }
})
}, [pluginById])
const handleNewSession = useCallback(() => {
void newSession()
setActiveView({ view: 'chat' })
}, [newSession])
const handleOpenSession = useCallback(
(path: string) => {
void openSession(path)
setActiveView({ view: 'chat' })
},
[openSession]
)
const handleOpenPlugin = useCallback(
(id: string) => {
handleOpenView(id, undefined)
},
[handleOpenView]
)
useEffect(() => {
try {
localStorage.setItem(SIDE_STORAGE_KEY, JSON.stringify(side))
@@ -147,16 +227,18 @@ export default function App(): JSX.Element {
return stopWatching
}, [])
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗
// 全局快捷键:Ctrl+N 新会话(全局,切回聊天)/ Ctrl+K 搜索 / Ctrl+, 设置(后两者仅聊天视图)
// Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗(不切视图,回聊天走侧栏点击)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
const inChat = activeViewRef.current.view === 'chat'
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
e.preventDefault()
newSession()
} else if (e.ctrlKey && e.key.toLowerCase() === 'k') {
handleNewSession()
} else if (inChat && e.ctrlKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(true)
} else if (e.ctrlKey && e.key === ',') {
} else if (inChat && e.ctrlKey && e.key === ',') {
e.preventDefault()
setSettingsOpen(true)
} else if (e.ctrlKey && (e.key === '=' || e.key === '+')) {
@@ -175,7 +257,7 @@ export default function App(): JSX.Element {
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [newSession])
}, [handleNewSession])
const handlePickSuggestion = (text: string): void => {
prefillRef.current = text
@@ -206,15 +288,32 @@ export default function App(): JSX.Element {
activeMeta?.firstMessage?.trim() ||
''
// 面板标签:注册表 → PanelTab(内容按 id 装配)
const panelTabs = sideTabIds.map((id) => {
const meta = PANEL_TAB_META[id]
return {
// 插件面板标签图标(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: meta.title,
icon: meta.icon,
content:
id === 'browser' ? (
title: '浏览器',
icon: LuGlobe as PanelTabIcon,
content: (
<BrowserPanel
state={browser.state}
suspended={settingsOpen || searchOpen || sideOverlay}
@@ -230,18 +329,46 @@ export default function App(): JSX.Element {
onDetectSources={browser.detectSources}
onImportProfile={browser.importProfile}
/>
) : null
)
}
]
}
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}
@@ -296,7 +441,7 @@ export default function App(): JSX.Element {
autoCompactionEnabled={state.sessionState?.autoCompactionEnabled ?? true}
onSend={send}
onStop={stop}
onNewSession={newSession}
onNewSession={handleNewSession}
onSetModel={setModel}
onOpenSettings={() => setSettingsOpen(true)}
onPermissionMode={setPermissionMode}
@@ -311,6 +456,32 @@ export default function App(): JSX.Element {
}
/>
</div>
{/* 插件整页视图(同一时刻一个;错误边界兜底,崩溃不拖垮宿主) */}
{activePlugin && (
<div className="relative flex min-w-0 flex-1 flex-col">
<PluginErrorBoundary
pluginName={activePlugin.manifest.name}
onBack={() => setActiveView({ view: 'chat' })}
>
{ActivePluginComponent ? (
<ActivePluginComponent />
) : activePlugin.hasPage ? (
<PluginPageFrame
plugin={activePlugin}
params={activeView.params}
reloadToken={pluginReloadToken}
onOpenView={handleOpenView}
/>
) : (
<div className="flex h-full items-center justify-center text-[12.5px] text-ink-3">
插件未提供页面
</div>
)}
</PluginErrorBoundary>
</div>
)}
{sideOpen && (
<SidePanel
tabs={panelTabs}
@@ -331,10 +502,10 @@ export default function App(): JSX.Element {
open={searchOpen}
onClose={() => setSearchOpen(false)}
sessions={sessions}
onNewSession={newSession}
onNewSession={handleNewSession}
onRefreshSessions={refreshSessions}
onOpenSettings={() => setSettingsOpen(true)}
onOpenSession={openSession}
onOpenSession={handleOpenSession}
/>
<SettingsModal
open={settingsOpen}
@@ -15,7 +15,8 @@ import {
import { LuPanelLeft } from 'react-icons/lu'
import { Button } from '@/components/atoms/Button'
import { GlideMenu } from '@/components/GlideMenu'
import type { SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
import { PluginIcon } from '@/plugins/PluginPageFrame'
import type { PluginInfo, SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
* 224px ↔ 56px 宽度过渡,文案随折叠淡出;导航行与会话行共用一个
@@ -171,7 +172,11 @@ export function SidebarNav({
onOpenSettings,
workspaces,
activeWorkspace,
onOpenWorkspace
onOpenWorkspace,
plugins,
activeView,
onOpenPlugin,
chatBadge
}: {
sessionState: SessionState | null
sessions: SessionMeta[]
@@ -184,6 +189,13 @@ export function SidebarNav({
workspaces: WorkspaceMeta[]
activeWorkspace?: WorkspaceMeta
onOpenWorkspace: (path: string) => void
/** 侧栏插件入口(已排序:order 升序,核心入口之后) */
plugins: PluginInfo[]
/** 当前视图('chat' 或插件 id;插件入口高亮用) */
activeView: string
onOpenPlugin: (id: string) => void
/** 代理活动徽标(插件视图期间聊天列隐藏,会话区头提示 待审批>出错>流式) */
chatBadge?: { approvals: number; error: boolean; streaming: boolean }
}): ReactNode {
const [collapsed, setCollapsed] = useState(() => {
try {
@@ -407,7 +419,7 @@ export function SidebarNav({
</>
)}
{/* 主导航(glide hover) */}
{/* 主导航(glide hover):核心入口 + 插件入口 */}
<GlideMenu className="gap-px" highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
<button type="button" data-row onClick={onNewSession} className={navRowClass}>
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">
@@ -421,6 +433,26 @@ export function SidebarNav({
</span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">搜索</span>
</button>
{plugins.map((plugin) => {
const active = activeView === plugin.manifest.id
return (
<button
type="button"
key={plugin.manifest.id}
data-row
title={plugin.manifest.description || plugin.manifest.name}
onClick={() => onOpenPlugin(plugin.manifest.id)}
className={`${navRowClass} ${active ? 'bg-hover-2 text-ink' : ''}`}
>
<span className="flex size-5 shrink-0 items-center justify-center">
<PluginIcon svg={plugin.manifest.icon} size={18} />
</span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">
{plugin.manifest.name}
</span>
</button>
)
})}
</GlideMenu>
{/* 会话列表区 */}
@@ -439,6 +471,25 @@ export function SidebarNav({
className={`shrink-0 transition-transform duration-150 ${chatsOpen ? '' : '-rotate-90'}`}
/>
<span>会话</span>
{/* 插件视图期间聊天列隐藏:会话区头徽标提示代理活动(待审批>出错>流式) */}
{chatBadge && activeView !== 'chat' && (chatBadge.approvals > 0 || chatBadge.error || chatBadge.streaming) && (
<span
title={
chatBadge.approvals > 0
? `${chatBadge.approvals} 个待审批`
: chatBadge.error
? 'Agent 出错/重试'
: 'Agent 回复中'
}
className={`ml-0.5 inline-block size-[7px] shrink-0 rounded-full ${
chatBadge.approvals > 0
? 'animate-pulse bg-orange'
: chatBadge.error
? 'bg-red'
: 'bg-accent'
}`}
/>
)}
</button>
{/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */}
@@ -1,20 +1,23 @@
import { useEffect, useState, type JSX } from 'react'
import { useEffect, useState, type JSX, type ReactNode } from 'react'
import { LuPanelRight } from 'react-icons/lu'
/**
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
* 整条是拖拽区;左上角展示当前会话名称(无名称时显示「新会话」);
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
*/
export function TitleBar({
title,
panelOpen,
onTogglePanel
onTogglePanel,
extra
}: {
title?: string
panelOpen: boolean
onTogglePanel: () => void
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
extra?: ReactNode
}): JSX.Element {
const name = title?.trim()
const [maximized, setMaximized] = useState(false)
@@ -24,6 +27,7 @@ export function TitleBar({
return (
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]">
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
{extra}
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
<button
type="button"
+13
View File
@@ -13,6 +13,9 @@ import type {
ModelInfo,
NavigateTreeOptions,
PermissionMode,
PluginInfo,
PluginsUiState,
PluginViewChange,
ResourceDetails,
SendImage,
SessionMeta,
@@ -111,6 +114,16 @@ declare global {
browserDetectSources: () => Promise<BrowserSource[]>
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
// ---- 插件系统 ----
pluginsList: () => Promise<PluginInfo[]>
pluginsUiState: () => Promise<PluginsUiState>
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
pluginsReload: () => Promise<void>
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise<void>
onPluginsListChanged: (callback: () => void) => () => void
onPluginsUiState: (callback: (state: PluginsUiState) => void) => () => void
onPluginsView: (callback: (change: PluginViewChange) => void) => () => void
}
}
}
+5 -3
View File
@@ -32,7 +32,8 @@ export const THEME_MODES: Array<{ id: ThemeMode; label: string }> = [
{ id: 'system', label: '跟随系统' }
]
/** 应用主题预设(undefined/未知 id = 恢复默认,移除内联覆盖) */
/** 应用主题预设(undefined/未知 id = 恢复默认,移除内联覆盖)。
* 变化时派发 aso-theme-changed(插件页桥据此把新 accent 下发给 iframe 页) */
export function applyThemeColor(id: string | undefined): void {
const preset = THEME_PRESETS.find((p) => p.id === id)
const root = document.documentElement
@@ -40,12 +41,13 @@ 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)
}
window.dispatchEvent(new CustomEvent('aso-theme-changed'))
}
/**
* 深浅色模式应用:主进程把用户选择写入 Electron nativeTheme.themeSource,
@@ -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)
)
}
+45
View File
@@ -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
}
+12
View File
@@ -67,3 +67,15 @@
- ✅ Electron 桌面端(本仓库 `apps/desktop`)
- ⛔ NestJS 后端、Ant Design Pro 后台、PostgreSQL —— 未设计,不创建、不实现
- 业务插件中依赖服务器 API 的部分,先留接口占位(不依赖后端即可运行的本地能力可先行)
## 5. D7 动态插件系统(两层能力模型)
harness 收敛为「壳 + 聊天 + 插件系统」,业务功能以插件交付(详见 docs/plugins.md):
- **两层能力**:内置插件(代码注册,宿主树组件 + 第一方工厂 + 全量宿主能力,浏览器面板即首个实例)/ 外置插件(`userData/plugins/`,iframe 隔离页 + 动态 import 的 main.mjs + 仅 SDK 桥接 API)。同 id 高 semver 胜、外置可遮蔽内置(组件级需 `replacesNative` 声明)→ **内置插件也能在线热更而不发安装包**。
- **pi 原生契约**:插件工厂收到 `{ pi, ui, storage }`——`pi` 为真实 `ExtensionAPI`,工具/事件/命令/消息走 `pi.registerTool` 等原生路径(harness 挂一个聚合 extension 扇出);`ui` 为 Electron 版槽位(对位 pi TUI 的 `ctx.ui.*`):侧栏入口/整页视图/面板 tab/状态胶囊/深链,内联 UI 一律数据驱动。
- **iframe 隔离的动机**:外置 UI 与宿主无 React 实例共享问题、插件崩溃不拖垮宿主、热重载 = 换 src;代价(跨桥交互、主题靠 tokens.css 连续)已接受。`plugin://`/`peer://` 特权协议 + CSP `frame-src plugin:` + 路径穿越防护 + origin/source 双重校验。
- **SDK 即 npm 包**(`packages/plugin-sdk`,Obsidian 模式):类型 + `definePlugin` + 页面桥 client(postMessage 白名单 invoke)+ hooks;仓库根 pnpm workspace(apps/* + packages/*)。
- **在线更新(M5)**:索引检查 → 下载 → ed25519 验签(公钥钉 harness)→ minContract 兼容检查 → 原子替换 → 热重载;自动/手动并存。
- **pi 原生目录(M7)**:pi resource-loader 默认已加载 `~/.pi/agent/extensions`(事实),M7 补开关与可见性管控。
+115
View File
@@ -0,0 +1,115 @@
# 插件系统(harness 插件契约)
> 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap)
> 契约版本:`HARNESS_CONTRACT_VERSION = 0.1.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载)
## 两层能力模型
| | 内置插件 | 外置插件 |
|---|---|---|
| 位置 | 代码注册(`src/main/plugins/builtin/index.ts`),UI 组件编译进宿主 bundle | `userData/plugins/<id>/`(manifest.json + 页面资产 + main.mjs) |
| UI 形态 | 宿主树 React 组件(`renderer/src/plugins/builtin-components.ts`,key=插件 id) | iframe 页 `plugin://<id>/<entry.page>`(自带 React,物理隔离) |
| main 侧 | 第一方工厂(不动态 import) | 动态 `import()`(cache-bust 重载) |
| 宿主能力 | 全量(BrowserSlot/原生视图协调/window.api) | 仅 SDK 桥接 API(postMessage,白名单 invoke) |
| 信任 | 随包分发,免签名 | 签名校验(M5 接入;当前未签名也可装载——TODO) |
| 管理 | 同一份 manifest/生命周期/管理页(可禁用) | 同左 + zip 导入/在线更新(M4/M5) |
同 id 冲突:高 semver 胜、平级外置胜;**外置遮蔽"组件级"内置必须声明 `replacesNative: true`**(能力降级声明,防原生视图能力被页面级外置静默顶掉)。
## manifest.json
```json
{
"id": "hotwords", // ^[a-z][a-z0-9-]*$
"name": "热点词",
"version": "1.2.0", // semver(x.y.z)
"minContract": "0.1.0",
"description": "…",
"icon": "<svg…/>", // 侧栏/面板图标(svg 字符串;宿主清除事件属性与 script)
"order": 10, // 侧栏排序(只在插件间;核心入口恒在前)
"panel": true, // 在右侧 SidePanel 出现一个 tab
"entry": { "page": "index.html", "main": "main.mjs" }, // 内置插件省略 entry
"replacesNative": false,
"updateURL": "https://…/index.json"
}
```
## 主进程插件契约(`@aso/plugin-sdk` 主入口)
```ts
import { definePlugin } from '@aso/plugin-sdk'
export default definePlugin((api) => {
// pi 原生扩展 API:工具/事件/命令/消息(语义与 pi CLI 扩展一致)
api.pi.registerTool(myTool)
api.pi.on('agent_end', () => { /* … */ })
// harness UI 槽位(数据驱动)
api.ui.setStatusItem('task', { text: '任务 3/5', tone: 'accent', openView: { view: 'hotwords', params: { id: 1 } } })
api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } })
api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页
// 插件私有 KV(userData/aso-plugin-storage.json,按 pluginId 隔离)
api.storage.set('lastQuery', 'xxx')
return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ }
})
```
实现:harness 在 pi `extensionFactories` 挂**一个聚合 extension**,工厂内创建真实 `ExtensionAPI` 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。
## 插件页(iframe)契约(`@aso/plugin-sdk/client`)
```ts
import { getHarnessClient, useHarnessTheme, useDeepLinkParams } from '@aso/plugin-sdk/client'
const harness = getHarnessClient('hotwords')
harness.openView('chat') // 深链返回聊天/其他插件
harness.setStatus('task', { text: '进行中' }) // 状态胶囊(写主进程单一事实源)
harness.invoke('getState') // 白名单:getState / send / getSettings
useDeepLinkParams('hotwords') // openView 携带的参数(含挂起重放)
useHarnessTheme('hotwords') // { dark, accent }(深浅色/accent 变化推送)
```
- 通信:postMessage(页 → `window.parent`;宿主 → iframe.contentWindow)
- 安全:宿主校验 `event.source === iframe.contentWindow` **且** `event.origin === 'plugin://<id>'`;sandbox 无 `allow-top-navigation`;iframe 页**无** window.api 直达
- 主题连续性:插件页 `<link rel="stylesheet" href="peer://assets/tokens.css">`(宿主 beautifului.css 的 token 表,深浅色由 `.dark` 类 + 下发的 accent 生效);建议插件自带 Tailwind 按同 token 编译
## UI 槽位总览(对位 pi 的 ctx.ui)
| 槽位 | 机制 | 状态 |
|---|---|---|
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
| 插件整页 Main | 内置组件级 / 外置 iframe;聊天列 display:none 保活 | ✅ |
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件或 iframe) | ✅ |
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见 | ✅ |
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
| 聊天区浮动胶囊(B3) | 与 B2 同源数据,折叠/展开双态 | ⏳ 下版本 |
## 交互基线
- 整页替换:点插件入口 → 主列(聊天)隐藏保活;TitleBar 常驻(显示插件名 + 窗口控制 + 胶囊)
- 返回聊天:点会话/新会话/Ctrl+N(全局);Esc 只关弹窗、**不**切视图
- Ctrl+K / Ctrl+, 仅聊天视图;切回聊天自动重新吸底(ChatView `visible`)
- 侧栏"会话"区头徽标:插件视图期间提示代理活动(待审批 > 出错 > 流式中)
- 重载:主进程 `plugins:reload`(重扫 manifest + 重导入 main.mjs + 清内联 UI + 重建 runtime);渲染层收到列表变化后 iframe 换 src 整页重载(= pi `/reload` 等效)
## 安全清单
- `plugin://` 特权协议:路径解析后必须落在该插件目录内(防穿越);`peer://` 仅暴露白名单资产(tokens.css)
- CSP:宿主页只加 `frame-src plugin:`(iframe 方案不动 script-src)
- 外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——**签名校验前,仅自行放置的插件目录可装载**(M5 后强制)
## pi 原生目录(M7,未实现)
事实:pi resource-loader **默认自动加载** `~/.pi/agent/extensions/*.ts` 与 `<cwd>/.pi/extensions/*.ts`(`noExtensions` 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。M7 计划:设置开关(默认关 → `noExtensions`)+ 管理页可见/单禁 + 参与统一重载。
## 开发工作流(外置插件,M6 模板落地后)
```
packages/plugin-template → npm i(workspace 引 @aso/plugin-sdk)
页面:vite build --watch → dist/ # 改即重编
宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src
```
+22 -1
View File
@@ -11,7 +11,28 @@
| v0.3.0 | 模型与设置 | 模型选择、Provider/API key 管理(写入 ~/.pi auth)、系统提示词自定义、连通性测试 |
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
| 未来 | | electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、CDP 插件、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
---
## v0.8.0 — 动态插件系统(M0–M3)
**交付**:
- 仓库根 pnpm workspace(apps/* + packages/*)+ `@aso/plugin-sdk`(类型 + definePlugin + 页面桥 client/hooks;源码形态被 workspace 消费,无构建步骤)
- 主进程 `PluginHost`:内置(代码注册)+ 外置(`userData/plugins/`,manifest 校验/契约版本检查/main.mjs 动态 import)双目录合并(高 semver 胜、外置遮蔽组件级内置需 replacesNative);聚合 InlineExtension 扇出真实 pi `ExtensionAPI`;内联 UI(状态胶囊/工具栏按钮)主进程单一事实源 + 事件广播;插件 KV 存储;`plugin://`(路径穿越防护)/`peer://`(tokens.css)特权协议;CSP 加 `frame-src plugin:`;插件启停/重载联动 runtime 重建
- 渲染层:插件列表/内联 UI hooks;`PluginPageFrame`(iframe + 桥宿主侧:origin+source 双校验、白名单 invoke、主题下发、深链参数、热重载换 src);`PluginErrorBoundary`;`PluginStatusCapsules`(TitleBar 槽,两视图可见);App 视图切换(activeView{view,params}、聊天列 display:none 保活、Ctrl+N 全局/Ctrl+K,Ctrl+, 仅聊天、插件禁用自动回落);SidebarNav 插件入口 + 会话区头代理活动徽标(待审批>出错>流式);SidePanel 零改动承载插件 tab
- 浏览器面板 → 内置插件:browserTools 迁 `pi.registerTool`(customTools 摘除)、面板 tab 按启用态门控、状态胶囊示范("浏览器 N 页")
- 验证:typecheck/build 通过(含 SDK 打包、tokens.css 构建期拷贝);dev 启动截图核验(聊天布局无回归、浏览器面板正常、无报错)
- 关键事实澄清:pi resource-loader **默认自动加载** `~/.pi/agent/extensions`——M7(原生目录管控)从"建设加载"降为"开关+可见性"
**自省**(v0.8.0 → 指导 M4+):
1. **外置插件尚未强制签名**——当前 `userData/plugins/` 直接装载(代码里已留 TODO);M5 验签落地前不对外分发任何外置插件。
2. **B3/B4/B5 槽位数据已通、渲染未接**——状态胶囊已在 TitleBar 生效;Composer 工具行按钮、聊天流围栏块元组件(data-table 等)、聊天区浮动胶囊排 M4 同批或下版本。
3. **切换回聊天未自动聚焦输入框**——保活后焦点丢失,需 Composer 暴露 focus 入口(data 属性 + App effect)。
4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。
5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。
6. **workspace 迁移副作用**——根 workspace 化后 electron postinstall 偶发不跑("Electron uninstall"),手动 `node node_modules/.pnpm/electron@*/node_modules/electron/install.js` 修复;新克隆/重装后注意。
---
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@aso/harness",
"private": true,
"version": "0.7.0",
"description": "Inkreach Agent Harness(壳 + 聊天 + 插件系统;业务功能以插件形式交付)",
"scripts": {
"typecheck": "pnpm -r --if-present typecheck",
"build": "pnpm -r --if-present build",
"dev": "pnpm --dir apps/desktop dev"
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@aso/plugin-sdk",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Inkreach harness 插件契约:清单/主进程工厂类型 + definePlugin + 插件页桥接 client(主入口给 main.mjs 作者,./client 给 iframe 页作者)",
"exports": {
".": "./src/index.ts",
"./client": "./src/page.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.0"
},
"peerDependencies": {
"react": ">=19"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"devDependencies": {
"@types/react": "19.2.18",
"react": "19.2.8",
"typescript": "7.0.2"
}
}
+158
View File
@@ -0,0 +1,158 @@
/**
* 插件页(iframe)侧桥接 client。
* 用法(插件页入口):`import { getHarnessClient } from '@aso/plugin-sdk/client'`
* 通信:postMessage —— 页 → window.parent;宿主 → iframe.contentWindow。
* 宿主严格校验消息来源 origin(plugin://<id>);invoke 仅暴露白名单方法。
*/
import type { OpenViewAction, StatusItem } from './status'
/** 桥协议版本(宿主与插件页握手比对;不匹配时宿主拒绝 invoke) */
export const HARNESS_BRIDGE_VERSION = 1
/** 宿主 → 插件页 */
export type HostToPluginMessage =
| { v: number; type: 'harness:hello'; contract: string }
| { v: number; type: 'harness:theme'; dark: boolean; accent?: string }
| { v: number; type: 'harness:deep-link'; params?: unknown }
| { v: number; type: 'harness:agent'; event: HarnessAgentEvent }
| { v: number; type: 'harness:invoke-result'; seq: number; ok: true; value: unknown }
| { v: number; type: 'harness:invoke-result'; seq: number; ok: false; error: string }
/** 插件页 → 宿主 */
export type PluginToHostMessage =
| { v: number; type: 'plugin:hello'; pluginId: string }
| { v: number; type: 'plugin:open-view'; view: string; params?: unknown }
| { v: number; type: 'plugin:set-status'; id: string; item: StatusItem | null }
| { v: number; type: 'plugin:invoke'; seq: number; method: string; args: unknown[] }
/** 宿主中继的 agent 事件(宽松类型:具体事件形状由宿主版本决定,插件页防御性读取) */
export interface HarnessAgentEvent {
type: string
[key: string]: unknown
}
/** invoke 白名单(宿主桥逐方法放行;后续按需扩充) */
export interface HarnessInvokeMap {
getState(): unknown
send(text: string, images?: { mimeType: string; data: string }[]): void
getSettings(): unknown
}
export interface HarnessTheme {
dark: boolean
accent?: string
}
export interface HarnessClient {
/** 握手完成(收到 harness:hello)后 resolve */
ready: Promise<void>
/** 宿主契约版本(ready 后可用) */
contractVersion(): string
/** 深链打开视图 */
openView(view: string, params?: unknown): void
/** 写/清一条状态胶囊(item=null 移除) */
setStatus(id: string, item: StatusItem | null): void
/** 深链参数:页面被 openView 打开/更新时回调(含挂起期间的一次重放) */
onDeepLink(cb: (params: unknown) => void): () => void
/** 主题变化(深浅色/accent) */
onTheme(cb: (theme: HarnessTheme) => void): () => void
/** agent 事件中继(宿主挑选的子集:state/agent_start/agent_end/stats…) */
onAgentEvent(cb: (event: HarnessAgentEvent) => void): () => void
/** 调用宿主白名单 API */
invoke<M extends keyof HarnessInvokeMap>(
method: M,
...args: Parameters<HarnessInvokeMap[M]>
): Promise<Awaited<ReturnType<HarnessInvokeMap[M]>>>
}
type DeepLinkCb = (params: unknown) => void
type ThemeCb = (theme: HarnessTheme) => void
type AgentCb = (event: HarnessAgentEvent) => void
let cached: HarnessClient | null = null
/** 获取桥接 client(单例;首个调用即开始握手) */
export function getHarnessClient(pluginId: string): HarnessClient {
if (cached) return cached
let contract = ''
let resolveReady: (() => void) | null = null
const ready = new Promise<void>((resolve) => {
resolveReady = resolve
})
const deepLinkCbs = new Set<DeepLinkCb>()
const themeCbs = new Set<ThemeCb>()
const agentCbs = new Set<AgentCb>()
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
let seq = 0
window.addEventListener('message', (event: MessageEvent) => {
const msg = event.data as HostToPluginMessage | undefined
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string' || !msg.type.startsWith('harness:')) return
switch (msg.type) {
case 'harness:hello':
contract = msg.contract
resolveReady?.()
resolveReady = null
break
case 'harness:theme':
for (const cb of themeCbs) cb({ dark: msg.dark, accent: msg.accent })
break
case 'harness:deep-link':
for (const cb of deepLinkCbs) cb(msg.params)
break
case 'harness:agent':
for (const cb of agentCbs) cb(msg.event)
break
case 'harness:invoke-result': {
const waiter = pending.get(msg.seq)
if (!waiter) break
pending.delete(msg.seq)
if (msg.ok) waiter.resolve(msg.value)
else waiter.reject(new Error(msg.error))
break
}
default:
break
}
})
const post = (msg: PluginToHostMessage): void => {
// 宿主 origin 随运行模式不同(file:// 或 dev server),用 '*' 发送;
// 方向安全由宿主侧的 origin 白名单校验保证
window.parent?.postMessage({ ...msg, v: HARNESS_BRIDGE_VERSION }, '*')
}
const invokeRaw = (method: string, ...args: unknown[]): Promise<unknown> =>
new Promise((resolve, reject) => {
const id = ++seq
pending.set(id, { resolve, reject })
post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:invoke', seq: id, method, args })
})
post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:hello', pluginId })
cached = {
ready,
contractVersion: () => contract,
openView: (view, params) => post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:open-view', view, params }),
setStatus: (id, item) => post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:set-status', id, item }),
onDeepLink(cb) {
deepLinkCbs.add(cb)
return () => void deepLinkCbs.delete(cb)
},
onTheme(cb) {
themeCbs.add(cb)
return () => void themeCbs.delete(cb)
},
onAgentEvent(cb) {
agentCbs.add(cb)
return () => void agentCbs.delete(cb)
},
invoke: invokeRaw as HarnessClient['invoke']
}
return cached
}
export type { OpenViewAction, StatusItem }
+47
View File
@@ -0,0 +1,47 @@
/**
* 插件页 React hooks(peer react,可选使用)。
* import { useHarnessTheme, useDeepLinkParams } from '@aso/plugin-sdk/client'
*/
import { useEffect, useState } from 'react'
import { getHarnessClient, type HarnessAgentEvent, type HarnessTheme } from './client'
/** 当前主题(握手完成前为 null) */
export function useHarnessTheme(pluginId: string): HarnessTheme | null {
const [theme, setTheme] = useState<HarnessTheme | null>(null)
useEffect(() => {
const client = getHarnessClient(pluginId)
let latest: HarnessTheme | null = null
const off = client.onTheme((t) => {
latest = t
setTheme(t)
})
void client.ready.then(() => setTheme(latest))
return off
}, [pluginId])
return theme
}
/** 最近一次深链参数(页面被 openView 打开/更新时变化;无深链为 undefined) */
export function useDeepLinkParams(pluginId: string): unknown {
const [params, setParams] = useState<unknown>(undefined)
useEffect(() => {
const client = getHarnessClient(pluginId)
const off = client.onDeepLink(setParams)
return off
}, [pluginId])
return params
}
/** 订阅宿主中继的 agent 事件(handler 变化不重订阅) */
export function useAgentEvent(pluginId: string, handler: (event: HarnessAgentEvent) => void): void {
useEffect(() => {
const client = getHarnessClient(pluginId)
const ref = { handler }
const off = client.onAgentEvent((event) => ref.handler(event))
return () => {
ref.handler = () => undefined
off()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId])
}
+7
View File
@@ -0,0 +1,7 @@
/**
* @aso/plugin-sdk 主入口(插件 main.mjs / 内置插件工厂使用)。
* 插件页(iframe)请改用 '@aso/plugin-sdk/client' 子入口(不含 pi SDK 类型依赖路径)。
*/
export * from './manifest'
export * from './status'
export * from './plugin'
+37
View File
@@ -0,0 +1,37 @@
/**
* 插件清单类型(插件根目录 manifest.json,内置/外置共用一份契约)。
* 本模块不得引入 pi SDK 或 React 类型——它同时被主进程与插件页消费。
*/
/** 插件 id 命名规则:小写字母开头,小写字母/数字/连字符 */
export const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]*$/
export interface PluginManifest {
/** 唯一 id(内置/外置同 id 时高 semver 胜、平级外置胜) */
id: string
name: string
/** semver;在线更新与遮蔽判定依据 */
version: string
/** 最低 harness 契约版本(semver),不满足拒载 */
minContract: string
description?: string
/** 侧栏入口图标:svg 字符串;缺省用宿主默认字形 */
icon?: string
/** 侧栏入口排序(升序,只在插件之间排序;核心入口恒在前) */
order?: number
/** 在右侧 SidePanel 出现为一个 tab */
panel?: boolean
/** 外置插件入口(相对插件根的路径)。
* 内置插件留空:UI 组件与 main 工厂编译进宿主 bundle。 */
entry?: {
/** 插件页入口(iframe 加载 `plugin://<id>/<page>`) */
page?: string
/** 主进程模块(动态 import,默认导出 definePlugin 工厂) */
main?: string
}
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
replacesNative?: boolean
/** 更新索引地址(JSON;格式见 docs/plugins.md) */
updateURL?: string
}
+3
View File
@@ -0,0 +1,3 @@
/** '@aso/plugin-sdk/client' 子入口:插件页(iframe)桥接 client + hooks */
export * from './client'
export * from './hooks'
+44
View File
@@ -0,0 +1,44 @@
/**
* 主进程侧插件契约(main.mjs / 内置插件工厂共用)。
* 生命周期对齐 pi 原生扩展:工厂函数收到 api,返回 dispose(可选)。
*/
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
import type { PluginManifest } from './manifest'
import type { StatusItem, ToolbarButtonDef } from './status'
/** 插件私有 KV 存储(主进程落盘 userData/aso-plugin-storage.json,按 pluginId 命名空间隔离) */
export interface PluginStorage {
get<T>(key: string): T | undefined
set(key: string, value: unknown): void
delete(key: string): void
}
/** harness 侧 UI 能力(Electron 版槽位,语义对位 pi TUI 的 ctx.ui.*)。
* 内联槽位全部数据驱动;插件页内 UI 由插件页自理,不经此 API。 */
export interface HarnessUiApi {
/** 打开视图('chat' 或插件 id),携可选深链参数 */
openView(view: string, params?: unknown): void
/** 写/更新一条状态胶囊(按 id 键控;插件 dispose 时宿主自动清理其全部条目) */
setStatusItem(id: string, item: StatusItem): void
removeStatusItem(id: string): void
/** 注册输入区工具行按钮(按 id 键控;dispose 自动清理) */
registerToolbarButton(button: ToolbarButtonDef): void
removeToolbarButton(id: string): void
}
/** 插件工厂收到的完整 API */
export interface HarnessPluginApi {
/** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */
pi: ExtensionAPI
ui: HarnessUiApi
storage: PluginStorage
manifest: PluginManifest
}
/** 插件工厂:同步或异步返回 dispose */
export type HarnessPluginFactory = (api: HarnessPluginApi) => void | (() => void) | Promise<void | (() => void)>
/** 身份函数:为插件作者提供完整类型推断 */
export function definePlugin(factory: HarnessPluginFactory): HarnessPluginFactory {
return factory
}
+29
View File
@@ -0,0 +1,29 @@
/**
* 内联槽位的数据类型(B2 状态胶囊 / B5 工具栏按钮)。
* 契约原则:内联 UI 一律"数据 + 深链",不注册组件——外置插件只有
* iframe 页有能力渲染自己的 React;宿主统一按数据渲染这些小 UI。
*/
/** 深链:点击行为 = 打开某个视图('chat' 或插件 id)并携带参数 */
export interface OpenViewAction {
view: string
params?: unknown
}
/** 状态胶囊条目(TitleBar 胶囊区 / 聊天区浮动胶囊共用一份数据) */
export interface StatusItem {
text: string
tone?: 'default' | 'accent' | 'warn' | 'danger'
/** 点击行为:深链;缺省不可点击 */
openView?: OpenViewAction
}
/** 输入区工具行按钮 */
export interface ToolbarButtonDef {
id: string
title: string
/** svg 字符串(16-20px 线条风格,宿主按 currentColor 渲染) */
icon?: string
/** 点击行为:深链;缺省不可点击 */
openView?: OpenViewAction
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
+98 -77
View File
@@ -6,7 +6,9 @@ settings:
importers:
.:
.: {}
apps/desktop:
dependencies:
'@earendil-works/pi-ai':
specifier: 0.85.0
@@ -69,6 +71,9 @@ importers:
specifier: 3.6.0
version: 3.6.0
devDependencies:
'@aso/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
'@tailwindcss/vite':
specifier: 4.3.3
version: 4.3.3(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
@@ -103,6 +108,22 @@ importers:
specifier: 8.2.2
version: 8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)
packages/plugin-sdk:
dependencies:
'@earendil-works/pi-coding-agent':
specifier: 0.85.0
version: 0.85.0(ws@8.21.3)
devDependencies:
'@types/react':
specifier: 19.2.18
version: 19.2.18
react:
specifier: 19.2.8
version: 19.2.8
typescript:
specifier: 7.0.2
version: 7.0.2
packages:
'@anthropic-ai/sdk@0.123.0':
@@ -818,8 +839,8 @@ packages:
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@oxc-project/types@0.148.0':
resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==}
'@oxc-project/types@0.149.0':
resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==}
'@peculiar/asn1-schema@2.9.4':
resolution: {integrity: sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==}
@@ -863,92 +884,92 @@ packages:
'@protobufjs/utf8@1.1.2':
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
'@rolldown/binding-android-arm-eabi@1.2.7':
resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==}
'@rolldown/binding-android-arm-eabi@1.2.8':
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/binding-android-arm64@1.2.7':
resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==}
'@rolldown/binding-android-arm64@1.2.8':
resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.2.7':
resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==}
'@rolldown/binding-darwin-arm64@1.2.8':
resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.2.7':
resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==}
'@rolldown/binding-darwin-x64@1.2.8':
resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.2.7':
resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==}
'@rolldown/binding-freebsd-x64@1.2.8':
resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.2.7':
resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==}
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.2.7':
resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==}
'@rolldown/binding-linux-arm64-gnu@1.2.8':
resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@rolldown/binding-linux-arm64-musl@1.2.7':
resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==}
'@rolldown/binding-linux-arm64-musl@1.2.8':
resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@rolldown/binding-linux-ppc64-gnu@1.2.7':
resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==}
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
'@rolldown/binding-linux-s390x-gnu@1.2.7':
resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==}
'@rolldown/binding-linux-s390x-gnu@1.2.8':
resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
'@rolldown/binding-linux-x64-gnu@1.2.7':
resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==}
'@rolldown/binding-linux-x64-gnu@1.2.8':
resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@rolldown/binding-linux-x64-musl@1.2.7':
resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==}
'@rolldown/binding-linux-x64-musl@1.2.8':
resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@rolldown/binding-openharmony-arm64@1.2.7':
resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==}
'@rolldown/binding-openharmony-arm64@1.2.8':
resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-win32-arm64-msvc@1.2.7':
resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==}
'@rolldown/binding-win32-arm64-msvc@1.2.8':
resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.2.7':
resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==}
'@rolldown/binding-win32-x64-msvc@1.2.8':
resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -1653,8 +1674,8 @@ packages:
electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
electron-to-chromium@1.5.423:
resolution: {integrity: sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==}
electron-to-chromium@1.5.425:
resolution: {integrity: sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==}
electron-vite@5.0.0:
resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==}
@@ -2510,8 +2531,8 @@ packages:
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
node-releases@2.0.54:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
node-releases@2.0.55:
resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==}
engines: {node: '>=18'}
nopt@9.0.0:
@@ -2743,8 +2764,8 @@ packages:
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
engines: {node: '>=8.0'}
rolldown@1.2.7:
resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==}
rolldown@1.2.8:
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -3946,7 +3967,7 @@ snapshots:
'@noble/hashes@2.4.0': {}
'@oxc-project/types@0.148.0': {}
'@oxc-project/types@0.149.0': {}
'@peculiar/asn1-schema@2.9.4':
dependencies:
@@ -3990,49 +4011,49 @@ snapshots:
'@protobufjs/utf8@1.1.2': {}
'@rolldown/binding-android-arm-eabi@1.2.7':
'@rolldown/binding-android-arm-eabi@1.2.8':
optional: true
'@rolldown/binding-android-arm64@1.2.7':
'@rolldown/binding-android-arm64@1.2.8':
optional: true
'@rolldown/binding-darwin-arm64@1.2.7':
'@rolldown/binding-darwin-arm64@1.2.8':
optional: true
'@rolldown/binding-darwin-x64@1.2.7':
'@rolldown/binding-darwin-x64@1.2.8':
optional: true
'@rolldown/binding-freebsd-x64@1.2.7':
'@rolldown/binding-freebsd-x64@1.2.8':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.2.7':
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.2.7':
'@rolldown/binding-linux-arm64-gnu@1.2.8':
optional: true
'@rolldown/binding-linux-arm64-musl@1.2.7':
'@rolldown/binding-linux-arm64-musl@1.2.8':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.2.7':
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.2.7':
'@rolldown/binding-linux-s390x-gnu@1.2.8':
optional: true
'@rolldown/binding-linux-x64-gnu@1.2.7':
'@rolldown/binding-linux-x64-gnu@1.2.8':
optional: true
'@rolldown/binding-linux-x64-musl@1.2.7':
'@rolldown/binding-linux-x64-musl@1.2.8':
optional: true
'@rolldown/binding-openharmony-arm64@1.2.7':
'@rolldown/binding-openharmony-arm64@1.2.8':
optional: true
'@rolldown/binding-win32-arm64-msvc@1.2.7':
'@rolldown/binding-win32-arm64-msvc@1.2.8':
optional: true
'@rolldown/binding-win32-x64-msvc@1.2.7':
'@rolldown/binding-win32-x64-msvc@1.2.8':
optional: true
'@rolldown/pluginutils@1.0.1': {}
@@ -4462,8 +4483,8 @@ snapshots:
dependencies:
baseline-browser-mapping: 2.11.21
caniuse-lite: 1.0.30001810
electron-to-chromium: 1.5.423
node-releases: 2.0.54
electron-to-chromium: 1.5.425
node-releases: 2.0.55
update-browserslist-db: 1.3.2(browserslist@4.28.9)
buffer-equal-constant-time@1.0.1: {}
@@ -4719,7 +4740,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-to-chromium@1.5.423: {}
electron-to-chromium@1.5.425: {}
electron-vite@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)):
dependencies:
@@ -5837,7 +5858,7 @@ snapshots:
node-int64@0.4.0: {}
node-releases@2.0.54: {}
node-releases@2.0.55: {}
nopt@9.0.0:
dependencies:
@@ -6114,26 +6135,26 @@ snapshots:
sprintf-js: 1.1.3
optional: true
rolldown@1.2.7:
rolldown@1.2.8:
dependencies:
'@oxc-project/types': 0.148.0
'@oxc-project/types': 0.149.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.7
'@rolldown/binding-android-arm64': 1.2.7
'@rolldown/binding-darwin-arm64': 1.2.7
'@rolldown/binding-darwin-x64': 1.2.7
'@rolldown/binding-freebsd-x64': 1.2.7
'@rolldown/binding-linux-arm-gnueabihf': 1.2.7
'@rolldown/binding-linux-arm64-gnu': 1.2.7
'@rolldown/binding-linux-arm64-musl': 1.2.7
'@rolldown/binding-linux-ppc64-gnu': 1.2.7
'@rolldown/binding-linux-s390x-gnu': 1.2.7
'@rolldown/binding-linux-x64-gnu': 1.2.7
'@rolldown/binding-linux-x64-musl': 1.2.7
'@rolldown/binding-openharmony-arm64': 1.2.7
'@rolldown/binding-win32-arm64-msvc': 1.2.7
'@rolldown/binding-win32-x64-msvc': 1.2.7
'@rolldown/binding-android-arm-eabi': 1.2.8
'@rolldown/binding-android-arm64': 1.2.8
'@rolldown/binding-darwin-arm64': 1.2.8
'@rolldown/binding-darwin-x64': 1.2.8
'@rolldown/binding-freebsd-x64': 1.2.8
'@rolldown/binding-linux-arm-gnueabihf': 1.2.8
'@rolldown/binding-linux-arm64-gnu': 1.2.8
'@rolldown/binding-linux-arm64-musl': 1.2.8
'@rolldown/binding-linux-ppc64-gnu': 1.2.8
'@rolldown/binding-linux-s390x-gnu': 1.2.8
'@rolldown/binding-linux-x64-gnu': 1.2.8
'@rolldown/binding-linux-x64-musl': 1.2.8
'@rolldown/binding-openharmony-arm64': 1.2.8
'@rolldown/binding-win32-arm64-msvc': 1.2.8
'@rolldown/binding-win32-x64-msvc': 1.2.8
safe-buffer@5.1.2: {}
@@ -6408,7 +6429,7 @@ snapshots:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.2.7
rolldown: 1.2.8
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.4.1
@@ -1,3 +1,7 @@
packages:
- apps/*
- packages/*
onlyBuiltDependencies:
- '@google/genai'
- electron