feat(plugins): 依赖互调 + SQLite 存储,契约 0.3.0
This commit is contained in:
@@ -23,10 +23,28 @@ function copyTokensCss(): Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构建期把插件存储 worker(免打包纯 ESM,只依赖 node 内置模块)拷到 out/main/ 旁:
|
||||||
|
* 主进程按 import.meta.url 定位它——必须与主进程产物同目录 */
|
||||||
|
function copyStorageWorker(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'aso-copy-storage-worker',
|
||||||
|
closeBundle() {
|
||||||
|
const src = resolve(__dirname, 'src/main/plugins/storage-worker.mjs')
|
||||||
|
const outDir = resolve(__dirname, 'out/main')
|
||||||
|
try {
|
||||||
|
mkdirSync(outDir, { recursive: true })
|
||||||
|
copyFileSync(src, resolve(outDir, 'storage-worker.mjs'))
|
||||||
|
} catch (err) {
|
||||||
|
this.warn(`storage-worker.mjs 拷贝失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
// pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持)
|
// pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持)
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin(), copyStorageWorker()],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载,
|
// electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载,
|
||||||
|
|||||||
@@ -470,6 +470,16 @@ export function registerIpc(): AgentService {
|
|||||||
if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle')
|
if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle')
|
||||||
return getPluginHost().setEnabled(id, enabled)
|
return getPluginHost().setEnabled(id, enabled)
|
||||||
})
|
})
|
||||||
|
// 打开插件私有数据目录(资源管理器中显示;不存在则先建)
|
||||||
|
handle('plugins:openDataDir', (_e, id: unknown) => {
|
||||||
|
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
|
||||||
|
return getPluginHost().openPluginDataDir(id)
|
||||||
|
})
|
||||||
|
// 清除插件全部存储数据(设置页二次确认后调用;连接关闭 + 删数据目录)
|
||||||
|
handle('plugins:clearData', (_e, id: unknown) => {
|
||||||
|
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
|
||||||
|
return getPluginHost().clearPluginData(id)
|
||||||
|
})
|
||||||
handle('plugins:reload', () => getPluginHost().reload())
|
handle('plugins:reload', () => getPluginHost().reload())
|
||||||
handle('plugins:openView', (_e, view: unknown, params: unknown) => {
|
handle('plugins:openView', (_e, view: unknown, params: unknown) => {
|
||||||
if (typeof view !== 'string' || !view) throw new Error('invalid view')
|
if (typeof view !== 'string' || !view) throw new Error('invalid view')
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
||||||
* ed25519 验签 + 开发者模式豁免)。
|
* ed25519 验签 + 开发者模式豁免)。
|
||||||
*/
|
*/
|
||||||
import { app, BrowserWindow, net, protocol } from 'electron'
|
import { app, BrowserWindow, net, protocol, shell } from 'electron'
|
||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||||
import { join, resolve, sep } from 'node:path'
|
import { join, resolve, sep } from 'node:path'
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
@@ -31,19 +31,21 @@ import type {
|
|||||||
PluginSettingValues,
|
PluginSettingValues,
|
||||||
PluginSettingsSchemas,
|
PluginSettingsSchemas,
|
||||||
PluginSettingsValuesMap,
|
PluginSettingsValuesMap,
|
||||||
|
PluginStorageSchema,
|
||||||
StatusItem,
|
StatusItem,
|
||||||
ToolbarButtonDef
|
ToolbarButtonDef
|
||||||
} from '@aso/plugin-sdk'
|
} from '@aso/plugin-sdk'
|
||||||
import { withTimeout } from '@aso/plugin-sdk'
|
import { PLUGIN_ID_PATTERN, withTimeout } from '@aso/plugin-sdk'
|
||||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
import type { PluginDepStatus, PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||||
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
||||||
|
import { PluginStorageService } from './plugin-storage'
|
||||||
import { getLogger } from '../logger'
|
import { getLogger } from '../logger'
|
||||||
|
|
||||||
const log = getLogger('plugin-host')
|
const log = getLogger('plugin-host')
|
||||||
const pluginLog = getLogger('plugin')
|
const pluginLog = getLogger('plugin')
|
||||||
|
|
||||||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||||
export const HARNESS_CONTRACT_VERSION = '0.2.0'
|
export const HARNESS_CONTRACT_VERSION = '0.3.0'
|
||||||
|
|
||||||
/** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */
|
/** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */
|
||||||
export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000
|
export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000
|
||||||
@@ -84,6 +86,34 @@ function compareSemver(a: string, b: string): number {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** semver 范围判定:支持 `*` / `^x.y.z` / `~x.y.z` / `>=x.y.z` / 精确 `x.y.z`(忽略预发布段) */
|
||||||
|
function satisfiesRange(version: string, range: string): boolean {
|
||||||
|
const v = parseSemver(version)
|
||||||
|
if (!v) return false
|
||||||
|
const r = range.trim()
|
||||||
|
if (!r || r === '*') return true
|
||||||
|
const m = /^(>=|\^|~)?\s*(\d+)\.(\d+)\.(\d+)$/.exec(r)
|
||||||
|
if (!m) return false
|
||||||
|
const cmp = compareSemver(version, `${m[2]}.${m[3]}.${m[4]}`)
|
||||||
|
if (m[1] === '^') return cmp >= 0 && v[0] === Number(m[2])
|
||||||
|
if (m[1] === '~') return cmp >= 0 && v[0] === Number(m[2]) && v[1] === Number(m[3])
|
||||||
|
if (m[1] === '>=') return cmp >= 0
|
||||||
|
return cmp === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依赖声明清洗:键须为合法插件 id、值须为受支持的范围语法;不合法条目丢弃(激活期按缺失处理) */
|
||||||
|
function sanitizeDeps(raw: unknown): Record<string, string> | undefined {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const [id, range] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (!PLUGIN_ID_PATTERN.test(id) || typeof range !== 'string') continue
|
||||||
|
const trimmed = range.trim()
|
||||||
|
if (!/^(\*|(>=|\^|~)?\d+\.\d+\.\d+)$/.test(trimmed)) continue
|
||||||
|
out[id] = trimmed
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : undefined
|
||||||
|
}
|
||||||
|
|
||||||
class PluginHost {
|
class PluginHost {
|
||||||
private resolved = new Map<string, ResolvedPlugin>()
|
private resolved = new Map<string, ResolvedPlugin>()
|
||||||
private disabled = new Set<string>()
|
private disabled = new Set<string>()
|
||||||
@@ -102,9 +132,12 @@ class PluginHost {
|
|||||||
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
||||||
private statusItems = new Map<string, Map<string, StatusItem>>()
|
private statusItems = new Map<string, Map<string, StatusItem>>()
|
||||||
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
|
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
|
||||||
/** 插件 KV 存储(pluginId → key → value) */
|
/** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */
|
||||||
private storage = new Map<string, Map<string, unknown>>()
|
private services = new Map<string, Map<string, unknown>>()
|
||||||
private storageSaveTimer: NodeJS.Timeout | null = null
|
/** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */
|
||||||
|
private storageSchemas = new Map<string, PluginStorageSchema>()
|
||||||
|
/** 插件 SQLite 存储服务(共享 worker;init 时创建) */
|
||||||
|
private storageService: PluginStorageService | null = null
|
||||||
|
|
||||||
private get externalRoot(): string {
|
private get externalRoot(): string {
|
||||||
return join(app.getPath('userData'), 'plugins')
|
return join(app.getPath('userData'), 'plugins')
|
||||||
@@ -114,10 +147,6 @@ class PluginHost {
|
|||||||
return join(app.getPath('userData'), 'aso-plugin-settings.json')
|
return join(app.getPath('userData'), 'aso-plugin-settings.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
private get storageFile(): string {
|
|
||||||
return join(app.getPath('userData'), 'aso-plugin-storage.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
private get valuesFile(): string {
|
private get valuesFile(): string {
|
||||||
return join(app.getPath('userData'), 'aso-plugin-values.json')
|
return join(app.getPath('userData'), 'aso-plugin-values.json')
|
||||||
}
|
}
|
||||||
@@ -130,6 +159,7 @@ class PluginHost {
|
|||||||
} catch {
|
} catch {
|
||||||
// 已存在或创建失败都继续(扫描时再报)
|
// 已存在或创建失败都继续(扫描时再报)
|
||||||
}
|
}
|
||||||
|
this.storageService = new PluginStorageService(join(app.getPath('userData'), 'plugin-data'))
|
||||||
log.info('plugin host init', {
|
log.info('plugin host init', {
|
||||||
externalRoot: this.externalRoot,
|
externalRoot: this.externalRoot,
|
||||||
externalLoadingEnabled: EXTERNAL_PLUGINS_ENABLED
|
externalLoadingEnabled: EXTERNAL_PLUGINS_ENABLED
|
||||||
@@ -152,16 +182,6 @@ class PluginHost {
|
|||||||
} catch {
|
} 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()
|
void this.scan()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +301,8 @@ class PluginHost {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
replacesNative: raw.replacesNative === true,
|
replacesNative: raw.replacesNative === true,
|
||||||
|
dependencies: sanitizeDeps(raw.dependencies),
|
||||||
|
optionalDependencies: sanitizeDeps(raw.optionalDependencies),
|
||||||
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
|
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -312,13 +334,16 @@ class PluginHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */
|
/** 重新扫描 + 重新导入 + 清 UI/服务/存储声明 + 重建 runtime(= pi /reload 等效) */
|
||||||
async reload(): Promise<void> {
|
async reload(): Promise<void> {
|
||||||
log.info('reload begin')
|
log.info('reload begin')
|
||||||
this.statusItems.clear()
|
this.statusItems.clear()
|
||||||
this.toolbarButtons.clear()
|
this.toolbarButtons.clear()
|
||||||
|
this.services.clear()
|
||||||
|
this.storageSchemas.clear()
|
||||||
await this.scan()
|
await this.scan()
|
||||||
this.send('plugins:uiState', this.uiState())
|
this.send('plugins:uiState', this.uiState())
|
||||||
|
this.send('plugins:listChanged', null)
|
||||||
if (this.rebuildRuntime) await this.rebuildRuntime()
|
if (this.rebuildRuntime) await this.rebuildRuntime()
|
||||||
log.info('reload done')
|
log.info('reload done')
|
||||||
}
|
}
|
||||||
@@ -452,11 +477,32 @@ class PluginHost {
|
|||||||
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
||||||
hasSettings: (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
hasSettings: (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
||||||
settingsNav: this.settingsNavSet.has(p.manifest.id) && (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
settingsNav: this.settingsNavSet.has(p.manifest.id) && (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
||||||
|
dependencies: this.depStatus(p),
|
||||||
|
storageSchema: this.storageSchemas.get(p.manifest.id),
|
||||||
error: p.error
|
error: p.error
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
|
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 依赖清单求值(管理页 Tooltip 数据源):必需 + 可选,逐项实时状态 */
|
||||||
|
private depStatus(p: ResolvedPlugin): PluginDepStatus[] {
|
||||||
|
const rows: PluginDepStatus[] = []
|
||||||
|
const add = (rec: Record<string, string> | undefined, required: boolean): void => {
|
||||||
|
for (const [depId, range] of Object.entries(rec ?? {})) {
|
||||||
|
const dep = this.resolved.get(depId)
|
||||||
|
let status: PluginDepStatus['status'] = 'ok'
|
||||||
|
if (!dep) status = 'missing'
|
||||||
|
else if (!this.isEnabled(depId)) status = 'disabled'
|
||||||
|
else if (dep.error) status = 'error'
|
||||||
|
else if (!satisfiesRange(dep.manifest.version, range)) status = 'version'
|
||||||
|
rows.push({ id: depId, range, required, status })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(p.manifest.dependencies, true)
|
||||||
|
add(p.manifest.optionalDependencies, false)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
getComponentKey(id: string): string | undefined {
|
getComponentKey(id: string): string | undefined {
|
||||||
const p = this.resolved.get(id)
|
const p = this.resolved.get(id)
|
||||||
return p?.componentKey
|
return p?.componentKey
|
||||||
@@ -531,14 +577,17 @@ class PluginHost {
|
|||||||
this.send('plugins:uiState', this.uiState())
|
this.send('plugins:uiState', this.uiState())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空某插件的全部内联 UI(禁用/重载路径);设置 schema 一并撤下(导航项随之消失) */
|
/** 清空某插件的内联 UI/服务注册/存储声明(禁用/重载/激活失败路径);设置 schema 一并撤下(导航项随之消失) */
|
||||||
private clearPluginUi(pluginId: string): void {
|
private clearPluginUi(pluginId: string): void {
|
||||||
this.statusItems.delete(pluginId)
|
this.statusItems.delete(pluginId)
|
||||||
this.toolbarButtons.delete(pluginId)
|
this.toolbarButtons.delete(pluginId)
|
||||||
this.settingsSchemas.delete(pluginId)
|
this.settingsSchemas.delete(pluginId)
|
||||||
this.dropSettingsCallbacks(pluginId)
|
this.dropSettingsCallbacks(pluginId)
|
||||||
|
this.services.delete(pluginId)
|
||||||
|
this.storageSchemas.delete(pluginId)
|
||||||
this.send('plugins:uiState', this.uiState())
|
this.send('plugins:uiState', this.uiState())
|
||||||
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
|
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
|
||||||
|
this.send('plugins:listChanged', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 深链 ----
|
// ---- 深链 ----
|
||||||
@@ -547,87 +596,168 @@ class PluginHost {
|
|||||||
this.send('plugins:view', { view, params })
|
this.send('plugins:view', { view, params })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 插件 KV 存储 ----
|
// ---- 插件数据目录(设置页「打开数据目录 / 清除数据」) ----
|
||||||
|
|
||||||
storageFor(pluginId: string) {
|
/** 插件数据目录路径(插件不存在返回 null) */
|
||||||
return {
|
pluginDataDir(id: string): string | null {
|
||||||
get: <T>(key: string): T | undefined => {
|
if (!this.resolved.has(id) || !this.storageService) return null
|
||||||
const kv = this.storage.get(pluginId)
|
return this.storageService.dataDir(id)
|
||||||
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
|
openPluginDataDir(id: string): boolean {
|
||||||
this.storageSaveTimer = setTimeout(() => {
|
const dir = this.pluginDataDir(id)
|
||||||
this.storageSaveTimer = null
|
if (!dir) return false
|
||||||
const doc: Record<string, Record<string, unknown>> = {}
|
|
||||||
for (const [pid, kv] of this.storage) doc[pid] = Object.fromEntries(kv)
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(this.storageFile, JSON.stringify(doc, null, 2), 'utf8')
|
mkdirSync(dir, { recursive: true })
|
||||||
|
} catch {
|
||||||
|
// 已存在或创建失败都继续(showItemInFolder 对存在路径展示)
|
||||||
|
}
|
||||||
|
shell.showItemInFolder(dir)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除插件全部存储数据(连接关闭 + 删数据目录;重装后插件从零开始) */
|
||||||
|
async clearPluginData(id: string): Promise<void> {
|
||||||
|
if (!this.resolved.has(id)) throw new Error(`插件 ${id} 不存在`)
|
||||||
|
if (!this.storageService) throw new Error('存储服务未初始化')
|
||||||
|
await this.storageService.clearData(id)
|
||||||
|
log.info('plugin data cleared by user', { pluginId: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 必需依赖静态预检(激活前):返回错误文案,null = 全部就绪。
|
||||||
|
* 软绑定:缺失/停用/版本不符只让本插件激活失败并提示行动,不自动安装、不级联停用。 */
|
||||||
|
private checkRequiredDeps(p: ResolvedPlugin): string | null {
|
||||||
|
const deps = p.manifest.dependencies
|
||||||
|
if (!deps) return null
|
||||||
|
for (const [depId, range] of Object.entries(deps)) {
|
||||||
|
const dep = this.resolved.get(depId)
|
||||||
|
if (!dep) return `缺少依赖插件 ${depId}(${range}),请安装后重载`
|
||||||
|
if (!this.isEnabled(depId)) return `依赖插件 ${depId} 已停用,请先启用`
|
||||||
|
if (dep.error) return `依赖插件 ${depId} 装载失败,无法激活`
|
||||||
|
if (!(dep.builtinFactory ?? dep.externalFactory)) return `依赖插件 ${depId} 未提供 main 工厂`
|
||||||
|
if (!satisfiesRange(dep.manifest.version, range)) {
|
||||||
|
return `依赖插件 ${depId} 需要 ${range},当前 v${dep.manifest.version}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kahn 分层拓扑排序(边 = 必需依赖 → 依赖方):依赖先激活、依赖者后激活,同波并行。
|
||||||
|
* 存在环时给环成员写明环路并返回 null(整环不激活)。 */
|
||||||
|
private topoWaves(plugins: ResolvedPlugin[]): ResolvedPlugin[][] | null {
|
||||||
|
const byId = new Map(plugins.map((p) => [p.manifest.id, p]))
|
||||||
|
const depsOf = new Map<string, string[]>()
|
||||||
|
for (const p of plugins) {
|
||||||
|
depsOf.set(
|
||||||
|
p.manifest.id,
|
||||||
|
Object.keys(p.manifest.dependencies ?? {}).filter((d) => byId.has(d))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// DFS 环检测:环成员集体报错(写明 a → b → a 环路)
|
||||||
|
const state = new Map<string, 1 | 2>()
|
||||||
|
const stack: string[] = []
|
||||||
|
let hasCycle = false
|
||||||
|
const visit = (id: string): void => {
|
||||||
|
const st = state.get(id)
|
||||||
|
if (st === 2) return
|
||||||
|
if (st === 1) {
|
||||||
|
const start = stack.indexOf(id)
|
||||||
|
const cycle = [...stack.slice(start), id].join(' → ')
|
||||||
|
for (const member of stack.slice(start)) {
|
||||||
|
const member2 = byId.get(member)
|
||||||
|
if (member2) member2.error = `循环依赖: ${cycle}`
|
||||||
|
}
|
||||||
|
hasCycle = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.set(id, 1)
|
||||||
|
stack.push(id)
|
||||||
|
for (const d of depsOf.get(id) ?? []) visit(d)
|
||||||
|
stack.pop()
|
||||||
|
state.set(id, 2)
|
||||||
|
}
|
||||||
|
for (const id of byId.keys()) visit(id)
|
||||||
|
if (hasCycle) return null
|
||||||
|
|
||||||
|
const indeg = new Map<string, number>()
|
||||||
|
const dependents = new Map<string, string[]>()
|
||||||
|
for (const [id, deps] of depsOf) {
|
||||||
|
indeg.set(id, deps.length)
|
||||||
|
for (const d of deps) {
|
||||||
|
const arr = dependents.get(d) ?? []
|
||||||
|
arr.push(id)
|
||||||
|
dependents.set(d, arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let frontier = [...indeg.entries()].filter(([, n]) => n === 0).map(([id]) => id)
|
||||||
|
const waves: ResolvedPlugin[][] = []
|
||||||
|
let remaining = plugins.length
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
waves.push(frontier.map((id) => byId.get(id)!))
|
||||||
|
remaining -= frontier.length
|
||||||
|
const next: string[] = []
|
||||||
|
for (const id of frontier) {
|
||||||
|
for (const dep of dependents.get(id) ?? []) {
|
||||||
|
const n = (indeg.get(dep) ?? 0) - 1
|
||||||
|
indeg.set(dep, n)
|
||||||
|
if (n === 0) next.push(dep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next
|
||||||
|
}
|
||||||
|
// 兜底:理论上环检测已拦;remaining > 0 说明存在未归零节点
|
||||||
|
return remaining > 0 ? null : waves
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 激活单个插件(前置:其全部必需依赖已在本轮 active)。异步工厂限时兜底;失败写 error 并清理。 */
|
||||||
|
private async activatePlugin(
|
||||||
|
p: ResolvedPlugin,
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
disposers: Array<() => void>,
|
||||||
|
active: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const id = p.manifest.id
|
||||||
|
const factory = p.builtinFactory ?? p.externalFactory
|
||||||
|
if (!factory) return
|
||||||
|
for (const depId of Object.keys(p.manifest.dependencies ?? {})) {
|
||||||
|
if (!active.has(depId)) {
|
||||||
|
p.error = `依赖插件 ${depId} 未能激活(详见该插件错误信息)`
|
||||||
|
this.clearPluginUi(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.resetSettingsCallbacks(id)
|
||||||
|
const markActive = (d: unknown): void => {
|
||||||
|
if (typeof d === 'function') disposers.push(d as () => void)
|
||||||
|
active.add(id)
|
||||||
|
log.info('plugin activated', { pluginId: id, source: p.source })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = factory(this.buildApi(p, pi))
|
||||||
|
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||||
|
// 异步工厂:限时兜底(挂死不拖垮后续波次),reject 必须接住(不得成为 unhandled rejection)
|
||||||
|
const d = await withTimeout(result as Promise<void | (() => void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${id} 激活`)
|
||||||
|
markActive(d)
|
||||||
|
} else {
|
||||||
|
markActive(result)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('save storage failed', { err })
|
p.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
log.error('plugin activation failed', { pluginId: id, err })
|
||||||
|
this.clearPluginUi(id)
|
||||||
}
|
}
|
||||||
}, 300)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 聚合 pi extension ----
|
/** 挂进 AgentService 的 extensionFactories;拓扑波次激活全部已启用插件 */
|
||||||
|
|
||||||
/** 挂进 AgentService 的 extensionFactories;工厂内激活全部已启用插件 */
|
|
||||||
createAggregatedExtension(): InlineExtension {
|
createAggregatedExtension(): InlineExtension {
|
||||||
return {
|
return {
|
||||||
name: 'aso-plugins',
|
name: 'aso-plugins',
|
||||||
factory: (pi: ExtensionAPI) => {
|
factory: (pi: ExtensionAPI) => {
|
||||||
const disposers: Array<() => void> = []
|
const disposers: Array<() => void> = []
|
||||||
for (const plugin of this.resolved.values()) {
|
this.services.clear()
|
||||||
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue
|
/** 本轮激活成功集合(波次推进与依赖方前置判定的依据) */
|
||||||
const factory = plugin.builtinFactory ?? plugin.externalFactory
|
const active = new Set<string>()
|
||||||
if (!factory) continue
|
|
||||||
this.resetSettingsCallbacks(plugin.manifest.id)
|
|
||||||
try {
|
|
||||||
const result = factory(this.buildApi(plugin, pi))
|
|
||||||
const pluginId = plugin.manifest.id
|
|
||||||
if (typeof result === 'function') {
|
|
||||||
disposers.push(result)
|
|
||||||
log.info('plugin activated', { pluginId, source: plugin.source })
|
|
||||||
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
||||||
// 异步工厂:限时兜底(挂死不拖垮 runtime 重建),reject 必须接住(不得成为 unhandled rejection)
|
|
||||||
void withTimeout(result as Promise<void | (() => void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${pluginId} 激活`)
|
|
||||||
.then((d) => {
|
|
||||||
if (typeof d === 'function') disposers.push(d)
|
|
||||||
log.info('plugin activated', { pluginId, source: plugin.source, async: true })
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
|
||||||
log.error('plugin activation failed', { pluginId, err })
|
|
||||||
this.clearPluginUi(pluginId)
|
|
||||||
this.send('plugins:listChanged', null)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
log.info('plugin activated', { pluginId, source: plugin.source })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
|
||||||
log.error('plugin activation failed', { pluginId: plugin.manifest.id, err })
|
|
||||||
this.clearPluginUi(plugin.manifest.id)
|
|
||||||
this.send('plugins:listChanged', null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pi.on('session_shutdown', () => {
|
pi.on('session_shutdown', () => {
|
||||||
for (const dispose of disposers) {
|
for (const dispose of disposers) {
|
||||||
try {
|
try {
|
||||||
@@ -638,6 +768,31 @@ class PluginHost {
|
|||||||
}
|
}
|
||||||
disposers.length = 0
|
disposers.length = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const candidates = [...this.resolved.values()].filter(
|
||||||
|
(p) => this.isEnabled(p.manifest.id) && !p.error && (p.builtinFactory ?? p.externalFactory)
|
||||||
|
)
|
||||||
|
// 静态预检:必需依赖不就绪的插件本轮直接判失败
|
||||||
|
let staticFailures = 0
|
||||||
|
for (const p of candidates) {
|
||||||
|
const failed = this.checkRequiredDeps(p)
|
||||||
|
if (failed) {
|
||||||
|
p.error = failed
|
||||||
|
staticFailures++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (staticFailures > 0) this.send('plugins:listChanged', null)
|
||||||
|
// 拓扑波次激活;环成员已在 topoWaves 里写明 error
|
||||||
|
const waves = this.topoWaves(candidates.filter((p) => !p.error))
|
||||||
|
if (!waves || waves.length === 0) {
|
||||||
|
if (!waves) this.send('plugins:listChanged', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
for (const wave of waves) {
|
||||||
|
await Promise.all(wave.map((p) => this.activatePlugin(p, pi, disposers, active)))
|
||||||
|
}
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,10 +815,39 @@ class PluginHost {
|
|||||||
}
|
}
|
||||||
return set
|
return set
|
||||||
})()
|
})()
|
||||||
|
if (!this.storageService) throw new Error('插件存储服务未初始化(宿主 init 未执行)')
|
||||||
|
const storage = this.storageService.forPlugin(id, (schema) => {
|
||||||
|
if (!schema || !Array.isArray(schema.tables)) return
|
||||||
|
this.storageSchemas.set(id, schema)
|
||||||
|
this.send('plugins:listChanged', null)
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
pi,
|
pi,
|
||||||
manifest: plugin.manifest,
|
manifest: plugin.manifest,
|
||||||
storage: this.storageFor(id),
|
storage,
|
||||||
|
/** 注册跨插件服务(按名键控;重载/rebuild 时随 services.clear() 清空) */
|
||||||
|
service: (name: string, impl: unknown): void => {
|
||||||
|
let svc = this.services.get(id)
|
||||||
|
if (!svc) {
|
||||||
|
svc = new Map()
|
||||||
|
this.services.set(id, svc)
|
||||||
|
}
|
||||||
|
svc.set(name, impl)
|
||||||
|
},
|
||||||
|
/** 取依赖插件的服务对象;依赖声明即访问授权——未声明直接抛错 */
|
||||||
|
requirePlugin: (targetId: string): Record<string, unknown> | undefined => {
|
||||||
|
const required = plugin.manifest.dependencies?.[targetId]
|
||||||
|
const optional = plugin.manifest.optionalDependencies?.[targetId]
|
||||||
|
if (required === undefined && optional === undefined) {
|
||||||
|
throw new Error(`插件 ${id} 未声明对 ${targetId} 的依赖(dependencies / optionalDependencies)`)
|
||||||
|
}
|
||||||
|
const svc = this.services.get(targetId)
|
||||||
|
if (!svc) {
|
||||||
|
if (optional !== undefined) return undefined
|
||||||
|
throw new Error(`依赖插件 ${targetId} 尚未就绪`)
|
||||||
|
}
|
||||||
|
return Object.fromEntries(svc)
|
||||||
|
},
|
||||||
log: {
|
log: {
|
||||||
debug: (message: string, kv?: Record<string, unknown>) => pluginLog.debug(message, { pluginId: id, ...kv }),
|
debug: (message: string, kv?: Record<string, unknown>) => pluginLog.debug(message, { pluginId: id, ...kv }),
|
||||||
info: (message: string, kv?: Record<string, unknown>) => pluginLog.info(message, { pluginId: id, ...kv }),
|
info: (message: string, kv?: Record<string, unknown>) => pluginLog.info(message, { pluginId: id, ...kv }),
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 插件存储服务(契约 0.3.0):每插件一个 SQLite 库(userData/plugin-data/<id>/data.db)。
|
||||||
|
* node:sqlite 的 DatabaseSync 是同步 API——全部连接放在共享 worker 线程里执行
|
||||||
|
* (storage-worker.mjs,免打包纯 ESM),主进程只做异步 RPC,插件查询永不阻塞主进程。
|
||||||
|
*
|
||||||
|
* KV(kv 表)由宿主管理:api.storage.kv 的底层,value JSON 编码;
|
||||||
|
* 插件自建表经 api.storage.db 的 run/query/exec/batch 执行(batch 以事务包裹)。
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { rm } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { Worker } from 'node:worker_threads'
|
||||||
|
import type { PluginStorage, PluginStorageSchema } from '@aso/plugin-sdk'
|
||||||
|
import { getLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = getLogger('plugin-storage')
|
||||||
|
|
||||||
|
/** 单个存储操作限时:本地 SQLite 正常毫秒级;超时视为 worker 挂死,不让插件无限等待 */
|
||||||
|
const STORAGE_OP_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
|
interface PendingCall {
|
||||||
|
resolve: (value: unknown) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginStorageService {
|
||||||
|
private worker: Worker | null = null
|
||||||
|
private pending = new Map<number, PendingCall>()
|
||||||
|
private reqSeq = 0
|
||||||
|
|
||||||
|
constructor(private dataRoot: string) {}
|
||||||
|
|
||||||
|
/** worker 文件与主进程产物同目录(构建期 copyStorageWorker 拷贝到 out/main/) */
|
||||||
|
private workerPath(): string {
|
||||||
|
return join(dirname(fileURLToPath(import.meta.url)), 'storage-worker.mjs')
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureWorker(): Worker {
|
||||||
|
if (this.worker) return this.worker
|
||||||
|
const file = this.workerPath()
|
||||||
|
if (!existsSync(file)) throw new Error(`存储 worker 缺失: ${file}`)
|
||||||
|
const w = new Worker(file)
|
||||||
|
w.on('message', (msg: { reqId: number; ok: boolean; result?: unknown; error?: string }) => {
|
||||||
|
const call = this.pending.get(msg.reqId)
|
||||||
|
if (!call) return
|
||||||
|
this.pending.delete(msg.reqId)
|
||||||
|
if (msg.ok) call.resolve(msg.result)
|
||||||
|
else call.reject(new Error(msg.error ?? '存储操作失败'))
|
||||||
|
})
|
||||||
|
const failAll = (err: Error): void => {
|
||||||
|
for (const call of this.pending.values()) call.reject(err)
|
||||||
|
this.pending.clear()
|
||||||
|
this.worker = null
|
||||||
|
}
|
||||||
|
w.on('error', (err: Error) => {
|
||||||
|
log.error('storage worker error', { err: err.message })
|
||||||
|
failAll(new Error(`存储 worker 崩溃: ${err.message}`))
|
||||||
|
})
|
||||||
|
w.on('exit', (code) => {
|
||||||
|
if (code !== 0) log.error('storage worker exited', { code })
|
||||||
|
failAll(new Error(`存储 worker 已退出(code=${code})`))
|
||||||
|
})
|
||||||
|
// dataRoot 经 init 下发(worker 不依赖 electron app 路径)
|
||||||
|
this.worker = w
|
||||||
|
this.rawCall(w, 'init', { dataRoot: this.dataRoot }).catch((err) => log.error('storage worker init failed', { err }))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不经 pending 表的内部调用(init 用) */
|
||||||
|
private rawCall(w: Worker, op: string, payload: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reqId = ++this.reqSeq
|
||||||
|
const onMessage = (msg: { reqId: number; ok: boolean; result?: unknown; error?: string }): void => {
|
||||||
|
if (msg.reqId !== reqId) return
|
||||||
|
w.off('message', onMessage)
|
||||||
|
if (msg.ok) resolve(msg.result)
|
||||||
|
else reject(new Error(msg.error ?? '存储操作失败'))
|
||||||
|
}
|
||||||
|
w.on('message', onMessage)
|
||||||
|
w.postMessage({ reqId, op, ...payload })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private call<T>(op: string, payload: Record<string, unknown>): Promise<T> {
|
||||||
|
const w = this.ensureWorker()
|
||||||
|
const reqId = ++this.reqSeq
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (this.pending.delete(reqId)) reject(new Error(`存储操作超时(${op},${STORAGE_OP_TIMEOUT_MS}ms)`))
|
||||||
|
}, STORAGE_OP_TIMEOUT_MS)
|
||||||
|
this.pending.set(reqId, {
|
||||||
|
resolve: (v) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(v as T)
|
||||||
|
},
|
||||||
|
reject: (e) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
w.postMessage({ reqId, op, ...payload })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 宿主侧管理 ----
|
||||||
|
|
||||||
|
/** 插件数据目录(id 已按 manifest 规则校验,可安全作路径段) */
|
||||||
|
dataDir(pluginId: string): string {
|
||||||
|
return join(this.dataRoot, pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭某插件的连接(清除数据前调用;一般重载无需关闭——连接跨重建复用) */
|
||||||
|
async closePlugin(pluginId: string): Promise<void> {
|
||||||
|
if (!this.worker) return
|
||||||
|
try {
|
||||||
|
await this.call('close', { pluginId })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('close plugin db failed', { pluginId, err: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除插件全部数据:关闭连接 + 删整个数据目录(设置页「清除数据」显式动作) */
|
||||||
|
async clearData(pluginId: string): Promise<void> {
|
||||||
|
await this.closePlugin(pluginId)
|
||||||
|
await rm(this.dataDir(pluginId), { recursive: true, force: true })
|
||||||
|
log.info('plugin data cleared', { pluginId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用退出时收尾(WAL 已逐语句持久化,失败也不阻塞退出) */
|
||||||
|
dispose(): void {
|
||||||
|
const w = this.worker
|
||||||
|
this.worker = null
|
||||||
|
for (const call of this.pending.values()) call.reject(new Error('存储服务已关闭'))
|
||||||
|
this.pending.clear()
|
||||||
|
void w?.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 插件 API(api.storage) ----
|
||||||
|
|
||||||
|
/** 组装某插件的存储 API;onSchema 由宿主提供(收集 declareSchema 声明) */
|
||||||
|
forPlugin(pluginId: string, onSchema: (schema: PluginStorageSchema) => void): PluginStorage {
|
||||||
|
return {
|
||||||
|
kv: {
|
||||||
|
get: (key) => this.call<unknown>('kvGet', { pluginId, key }).then((v) => (v === null ? undefined : (v as never))),
|
||||||
|
set: (key, value) => this.call('kvSet', { pluginId, key, value: value ?? null }),
|
||||||
|
delete: (key) => this.call('kvDelete', { pluginId, key }),
|
||||||
|
keys: () => this.call<string[]>('kvKeys', { pluginId })
|
||||||
|
},
|
||||||
|
db: {
|
||||||
|
exec: (sql) => this.call('exec', { pluginId, sql }),
|
||||||
|
run: (sql, params) => this.call('run', { pluginId, sql, params: params ?? [] }),
|
||||||
|
query: (sql, params) => this.call<Record<string, unknown>[]>('query', { pluginId, sql, params: params ?? [] }),
|
||||||
|
batch: (statements) => this.call('batch', { pluginId, statements })
|
||||||
|
},
|
||||||
|
dir: () => this.dataDir(pluginId),
|
||||||
|
declareSchema: (schema) => onSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 插件存储 worker(免打包纯 ESM,只依赖 node 内置模块;构建期拷到 out/main/ 旁)。
|
||||||
|
* 持有全部插件的 SQLite 连接(node:sqlite DatabaseSync),按消息逐个执行——
|
||||||
|
* 同步 API 全部留在本线程,主进程只做异步 RPC,插件查询永不阻塞主进程事件循环。
|
||||||
|
*
|
||||||
|
* 协议:主进程 → { reqId, op, ...payload };worker → { reqId, ok, result? | error? }。
|
||||||
|
* 首条消息必须是 init(下发 dataRoot,避免 worker 依赖 electron app 路径)。
|
||||||
|
*/
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { parentPort } from 'node:worker_threads'
|
||||||
|
|
||||||
|
/** dataRoot(userData/plugin-data);由 init 消息下发 */
|
||||||
|
let dataRoot = null
|
||||||
|
|
||||||
|
/** pluginId → 已打开连接(跨消息复用;close/clearData 时移除) */
|
||||||
|
const dbs = new Map()
|
||||||
|
|
||||||
|
function openDb(pluginId) {
|
||||||
|
let db = dbs.get(pluginId)
|
||||||
|
if (db) return db
|
||||||
|
// pluginId 已由宿主按 manifest 规则校验(小写字母开头,字母/数字/连字符),可安全作路径段
|
||||||
|
mkdirSync(join(dataRoot, pluginId), { recursive: true })
|
||||||
|
db = new DatabaseSync(join(dataRoot, pluginId, 'data.db'))
|
||||||
|
db.exec('PRAGMA journal_mode = WAL;')
|
||||||
|
// 宿主管理的 KV 表:api.storage.kv 的底层(value JSON 编码)
|
||||||
|
db.exec('CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
|
||||||
|
dbs.set(pluginId, db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(op, msg) {
|
||||||
|
switch (op) {
|
||||||
|
case 'init':
|
||||||
|
dataRoot = msg.dataRoot
|
||||||
|
return null
|
||||||
|
case 'close': {
|
||||||
|
const db = dbs.get(msg.pluginId)
|
||||||
|
if (db) {
|
||||||
|
dbs.delete(msg.pluginId)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case 'kvGet': {
|
||||||
|
const row = openDb(msg.pluginId).prepare('SELECT value FROM kv WHERE key = ?').get(msg.key)
|
||||||
|
return row ? JSON.parse(row.value) : null
|
||||||
|
}
|
||||||
|
case 'kvSet':
|
||||||
|
openDb(msg.pluginId)
|
||||||
|
.prepare('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.run(msg.key, JSON.stringify(msg.value ?? null))
|
||||||
|
return null
|
||||||
|
case 'kvDelete':
|
||||||
|
openDb(msg.pluginId).prepare('DELETE FROM kv WHERE key = ?').run(msg.key)
|
||||||
|
return null
|
||||||
|
case 'kvKeys':
|
||||||
|
return openDb(msg.pluginId)
|
||||||
|
.prepare('SELECT key FROM kv ORDER BY key')
|
||||||
|
.all()
|
||||||
|
.map((r) => r.key)
|
||||||
|
case 'exec':
|
||||||
|
openDb(msg.pluginId).exec(msg.sql)
|
||||||
|
return null
|
||||||
|
case 'run':
|
||||||
|
openDb(msg.pluginId).prepare(msg.sql).run(...(msg.params ?? []))
|
||||||
|
return null
|
||||||
|
case 'query':
|
||||||
|
return openDb(msg.pluginId).prepare(msg.sql).all(...(msg.params ?? []))
|
||||||
|
case 'batch': {
|
||||||
|
const db = openDb(msg.pluginId)
|
||||||
|
db.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const st of msg.statements ?? []) db.prepare(st.sql).run(...(st.params ?? []))
|
||||||
|
db.exec('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
db.exec('ROLLBACK')
|
||||||
|
} catch {
|
||||||
|
// 连接已坏时 ROLLBACK 自身会失败——原始错误优先
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`未知存储操作: ${op}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort.on('message', (msg) => {
|
||||||
|
if (!dataRoot && msg.op !== 'init') {
|
||||||
|
parentPort.postMessage({ reqId: msg.reqId, ok: false, error: '存储 worker 未初始化' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parentPort.postMessage({ reqId: msg.reqId, ok: true, result: handle(msg.op, msg) ?? null })
|
||||||
|
} catch (err) {
|
||||||
|
parentPort.postMessage({ reqId: msg.reqId, ok: false, error: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -170,6 +170,8 @@ const api = {
|
|||||||
},
|
},
|
||||||
pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> =>
|
pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> =>
|
||||||
ipcRenderer.invoke('plugins:setEnabled', id, enabled),
|
ipcRenderer.invoke('plugins:setEnabled', id, enabled),
|
||||||
|
pluginsOpenDataDir: (id: string): Promise<boolean> => ipcRenderer.invoke('plugins:openDataDir', id),
|
||||||
|
pluginsClearData: (id: string): Promise<void> => ipcRenderer.invoke('plugins:clearData', id),
|
||||||
pluginsReload: (): Promise<void> => ipcRenderer.invoke('plugins:reload'),
|
pluginsReload: (): Promise<void> => ipcRenderer.invoke('plugins:reload'),
|
||||||
pluginsOpenView: (view: string, params?: unknown): Promise<void> =>
|
pluginsOpenView: (view: string, params?: unknown): Promise<void> =>
|
||||||
ipcRenderer.invoke('plugins:openView', view, params),
|
ipcRenderer.invoke('plugins:openView', view, params),
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
LuArrowLeft,
|
LuArrowLeft,
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuCpu,
|
LuCpu,
|
||||||
|
LuDatabase,
|
||||||
LuFolderOpen,
|
LuFolderOpen,
|
||||||
LuKeyRound,
|
LuKeyRound,
|
||||||
|
LuLink2,
|
||||||
LuLogOut,
|
LuLogOut,
|
||||||
LuPlus,
|
LuPlus,
|
||||||
LuPuzzle,
|
LuPuzzle,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
LuSparkles,
|
LuSparkles,
|
||||||
LuTerminal,
|
LuTerminal,
|
||||||
LuTextQuote,
|
LuTextQuote,
|
||||||
|
LuTrash2,
|
||||||
LuX
|
LuX
|
||||||
} from 'react-icons/lu'
|
} from 'react-icons/lu'
|
||||||
import type { IconType } from 'react-icons'
|
import type { IconType } from 'react-icons'
|
||||||
@@ -23,6 +26,7 @@ import type {
|
|||||||
CustomProviderConfig,
|
CustomProviderConfig,
|
||||||
ModelCatalog,
|
ModelCatalog,
|
||||||
ModelMeta,
|
ModelMeta,
|
||||||
|
PluginDepStatus,
|
||||||
PluginInfo,
|
PluginInfo,
|
||||||
ProviderCatalogEntry,
|
ProviderCatalogEntry,
|
||||||
ResourceDetails,
|
ResourceDetails,
|
||||||
@@ -631,6 +635,124 @@ function ModelSettings({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 依赖状态展示元数据(状态色对齐状态胶囊语义:绿=可用 / 橙=待处理 / 红=失败) */
|
||||||
|
const DEP_STATUS_META: Record<PluginDepStatus['status'], { label: string; cls: string }> = {
|
||||||
|
ok: { label: '可用', cls: 'text-green' },
|
||||||
|
missing: { label: '未安装', cls: 'text-orange' },
|
||||||
|
disabled: { label: '已停用', cls: 'text-orange' },
|
||||||
|
version: { label: '版本不符', cls: 'text-orange' },
|
||||||
|
error: { label: '装载失败', cls: 'text-red' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依赖关系 Tooltip(管理页行内悬停):展示每个依赖的 id / 版本范围 / 必需性 / 实时状态。
|
||||||
|
* 必需依赖缺失或停用时本插件不可用——错误详情见行内红字,去启用在对应插件的行上操作。 */
|
||||||
|
function PluginDepsTip({ deps }: { deps: PluginDepStatus[] }): ReactNode {
|
||||||
|
return (
|
||||||
|
<span className="group relative inline-flex" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<LuLink2 size={11} className="cursor-help text-ink-3 transition-colors group-hover:text-ink-2" />
|
||||||
|
<span className="pointer-events-none invisible absolute left-1/2 top-full z-50 mt-1.5 flex -translate-x-1/2 flex-col gap-1 rounded-card border border-line bg-surface px-2.5 py-2 opacity-0 shadow-overlay transition-opacity duration-150 group-hover:visible group-hover:opacity-100">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-ink-3">依赖</span>
|
||||||
|
{deps.map((d) => (
|
||||||
|
<span key={`${d.id}:${d.required}`} className="flex items-center gap-1.5 whitespace-nowrap text-[11px] text-ink">
|
||||||
|
<span className="font-mono">{d.id}</span>
|
||||||
|
<span className="font-mono text-ink-3">{d.range}</span>
|
||||||
|
<span className="ml-auto pl-3 text-ink-3">{d.required ? '必需' : '可选'}</span>
|
||||||
|
<span className={`w-14 text-right ${DEP_STATUS_META[d.status].cls}`}>{DEP_STATUS_META[d.status].label}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件存储区(插件二级页):机器可读 schema 展示 + 数据目录/清除数据动作。
|
||||||
|
* 数据在 userData/plugin-data/<id>/data.db(每插件一库,宿主共享 worker 持有连接)。 */
|
||||||
|
function PluginStorageSection({
|
||||||
|
info,
|
||||||
|
onOpenDir,
|
||||||
|
onClearData
|
||||||
|
}: {
|
||||||
|
info: PluginInfo
|
||||||
|
onOpenDir: () => void
|
||||||
|
onClearData: () => void
|
||||||
|
}): ReactNode {
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const schema = info.storageSchema
|
||||||
|
return (
|
||||||
|
<div className="rounded-card border border-line">
|
||||||
|
<div className="flex items-center gap-2 border-b border-line px-3 py-2">
|
||||||
|
<LuDatabase size={13} className="shrink-0 text-ink-3" />
|
||||||
|
<span className="text-[12px] font-medium text-ink">存储区</span>
|
||||||
|
<span className="min-w-0 truncate text-[10.5px] text-ink-3" title={`userData/plugin-data/${info.manifest.id}`}>
|
||||||
|
SQLite · 插件私有
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenDir}
|
||||||
|
className="flex items-center gap-1 rounded-control border border-line-strong px-2 py-1 text-[11px] text-ink transition-colors hover:bg-hover"
|
||||||
|
>
|
||||||
|
<LuFolderOpen size={11} />
|
||||||
|
数据目录
|
||||||
|
</button>
|
||||||
|
{confirming ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirming(false)
|
||||||
|
onClearData()
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setConfirming(false)}
|
||||||
|
className="flex items-center gap-1 rounded-control bg-red px-2 py-1 text-[11px] font-medium text-white transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
<LuTrash2 size={11} />
|
||||||
|
再点一次确认
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className="flex items-center gap-1 rounded-control border border-line-strong px-2 py-1 text-[11px] text-ink-3 transition-colors hover:bg-hover hover:text-red"
|
||||||
|
>
|
||||||
|
<LuTrash2 size={11} />
|
||||||
|
清除数据
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{schema && (
|
||||||
|
<div className="flex flex-col gap-2.5 px-3 py-2.5">
|
||||||
|
{schema.kv && (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="shrink-0 rounded-[4px] bg-hover px-1.5 py-0.5 font-mono text-[10.5px] text-ink-2">kv</span>
|
||||||
|
<span className="min-w-0 text-[11px] leading-4 text-ink-3">{schema.kv.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schema.tables.map((t) => (
|
||||||
|
<div key={t.name}>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="shrink-0 rounded-[4px] bg-hover px-1.5 py-0.5 font-mono text-[10.5px] text-ink-2">{t.name}</span>
|
||||||
|
<span className="min-w-0 text-[11px] leading-4 text-ink-3">{t.description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-col gap-0.5 pl-2">
|
||||||
|
{t.columns.map((c) => (
|
||||||
|
<div key={c.name} className="flex items-baseline gap-2 text-[11px] leading-4">
|
||||||
|
<span className="w-28 shrink-0 truncate font-mono text-ink-2" title={c.name}>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<span className="w-16 shrink-0 font-mono text-ink-3">{c.type}</span>
|
||||||
|
<span className="min-w-0 text-ink-3">{c.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** 插件页:内置 / 全局 / 项目三个作用域统一管理(交互对齐技能页),行内启停开关;单一事实源在主进程,listChanged 驱动刷新 */
|
/** 插件页:内置 / 全局 / 项目三个作用域统一管理(交互对齐技能页),行内启停开关;单一事实源在主进程,listChanged 驱动刷新 */
|
||||||
function PluginsPanel({
|
function PluginsPanel({
|
||||||
togglingId,
|
togglingId,
|
||||||
@@ -733,7 +855,7 @@ function PluginsPanel({
|
|||||||
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) || a.manifest.id.localeCompare(b.manifest.id)
|
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) || a.manifest.id.localeCompare(b.manifest.id)
|
||||||
)
|
)
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const openSettings = p.hasSettings && p.enabled
|
const openSettings = p.enabled && (p.hasSettings || Boolean(p.storageSchema))
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.manifest.id}
|
key={p.manifest.id}
|
||||||
@@ -760,6 +882,7 @@ function PluginsPanel({
|
|||||||
<span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">
|
<span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">
|
||||||
{scope === 'builtin' ? '内置' : scope === 'user' ? '全局' : '项目'} v{p.manifest.version}
|
{scope === 'builtin' ? '内置' : scope === 'user' ? '全局' : '项目'} v{p.manifest.version}
|
||||||
</span>
|
</span>
|
||||||
|
{p.dependencies.length > 0 && <PluginDepsTip deps={p.dependencies} />}
|
||||||
</div>
|
</div>
|
||||||
{p.manifest.description && (
|
{p.manifest.description && (
|
||||||
<div className="mt-1 text-[12px] leading-5 text-ink-3">{p.manifest.description}</div>
|
<div className="mt-1 text-[12px] leading-5 text-ink-3">{p.manifest.description}</div>
|
||||||
@@ -1511,7 +1634,10 @@ export function SettingsModal({
|
|||||||
(() => {
|
(() => {
|
||||||
const pid = tab.slice(7)
|
const pid = tab.slice(7)
|
||||||
const schema = settingSchemas[pid]
|
const schema = settingSchemas[pid]
|
||||||
if (!schema || schema.items.length === 0) {
|
const info = plugins.find((p) => p.manifest.id === pid)
|
||||||
|
const hasForm = Boolean(schema && schema.items.length > 0)
|
||||||
|
const hasStorage = Boolean(info?.storageSchema)
|
||||||
|
if (!hasForm && !hasStorage) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-[12.5px] text-ink-3">
|
<div className="flex h-full items-center justify-center text-[12.5px] text-ink-3">
|
||||||
该插件未提供配置项(或插件已停用 / 移除)
|
该插件未提供配置项(或插件已停用 / 移除)
|
||||||
@@ -1519,13 +1645,22 @@ export function SettingsModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto pb-2 pr-1">
|
||||||
|
{hasForm && (
|
||||||
<PluginSettingsForm
|
<PluginSettingsForm
|
||||||
schema={schema}
|
schema={schema}
|
||||||
values={settingValues[pid] ?? {}}
|
values={settingValues[pid] ?? {}}
|
||||||
onChange={(key, value) => handleSetSettingValue(pid, key, value)}
|
onChange={(key, value) => handleSetSettingValue(pid, key, value)}
|
||||||
onAction={(key) => void window.api.pluginsSettingsAction(pid, key)}
|
onAction={(key) => void window.api.pluginsSettingsAction(pid, key)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{info && hasStorage && (
|
||||||
|
<PluginStorageSection
|
||||||
|
info={info}
|
||||||
|
onOpenDir={() => void window.api.pluginsOpenDataDir(pid).catch(() => undefined)}
|
||||||
|
onClearData={() => run('已清除插件数据', () => window.api.pluginsClearData(pid))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ declare global {
|
|||||||
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void) => () => void
|
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void) => () => void
|
||||||
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void) => () => void
|
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void) => () => void
|
||||||
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
|
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
|
||||||
|
pluginsOpenDataDir: (id: string) => Promise<boolean>
|
||||||
|
pluginsClearData: (id: string) => Promise<void>
|
||||||
pluginsReload: () => Promise<void>
|
pluginsReload: () => Promise<void>
|
||||||
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
|
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
|
||||||
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise<void>
|
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise<void>
|
||||||
|
|||||||
@@ -361,7 +361,18 @@ export type AgentUiEvent =
|
|||||||
|
|
||||||
// ===== 插件系统(契约类型来自 @aso/plugin-sdk) =====
|
// ===== 插件系统(契约类型来自 @aso/plugin-sdk) =====
|
||||||
|
|
||||||
import type { PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
import type { PluginManifest, PluginStorageSchema, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
||||||
|
|
||||||
|
/** 依赖状态(管理页 Tooltip 数据源):本插件 manifest 声明的每个依赖的实时可用性 */
|
||||||
|
export interface PluginDepStatus {
|
||||||
|
id: string
|
||||||
|
/** 声明的 semver 范围(^x.y.z / ~x.y.z / >=x.y.z / 精确 / *) */
|
||||||
|
range: string
|
||||||
|
/** true=必需(缺失/停用/版本不符 → 本插件激活失败);false=可选(只降级) */
|
||||||
|
required: boolean
|
||||||
|
/** ok=可用;missing=未安装;disabled=已停用;version=版本不符;error=自身装载失败 */
|
||||||
|
status: 'ok' | 'missing' | 'disabled' | 'version' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
/** 插件条目(管理页/侧栏入口数据源) */
|
/** 插件条目(管理页/侧栏入口数据源) */
|
||||||
export interface PluginInfo {
|
export interface PluginInfo {
|
||||||
@@ -382,6 +393,10 @@ export interface PluginInfo {
|
|||||||
hasSettings: boolean
|
hasSettings: boolean
|
||||||
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
|
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
|
||||||
settingsNav: boolean
|
settingsNav: boolean
|
||||||
|
/** 依赖清单及实时状态(manifest.dependencies + optionalDependencies 求值;无声明则空数组) */
|
||||||
|
dependencies: PluginDepStatus[]
|
||||||
|
/** 已声明的存储 schema(api.storage.declareSchema;未声明则缺省) */
|
||||||
|
storageSchema?: PluginStorageSchema
|
||||||
/** 装载/激活错误(不影响其他插件) */
|
/** 装载/激活错误(不影响其他插件) */
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export * from './manifest'
|
|||||||
export * from './settings'
|
export * from './settings'
|
||||||
export * from './async'
|
export * from './async'
|
||||||
export * from './status'
|
export * from './status'
|
||||||
|
export * from './storage'
|
||||||
export * from './plugin'
|
export * from './plugin'
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export interface PluginManifest {
|
|||||||
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
|
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
|
||||||
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
|
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
|
||||||
replacesNative?: boolean
|
replacesNative?: boolean
|
||||||
|
/** 必需依赖:插件 id → semver 范围(^x.y.z / ~x.y.z / >=x.y.z / 精确 x.y.z / *)。
|
||||||
|
* 缺失 / 已停用 / 版本不符 → 本插件激活失败并提示;api.requirePlugin 仅对声明过的依赖放行 */
|
||||||
|
dependencies?: Record<string, string>
|
||||||
|
/** 可选依赖:同范围语法;缺失或停用只降级不报错(requirePlugin 返回 undefined) */
|
||||||
|
optionalDependencies?: Record<string, string>
|
||||||
/** 更新索引地址(JSON;格式见 docs/plugins.md) */
|
/** 更新索引地址(JSON;格式见 docs/plugins.md) */
|
||||||
updateURL?: string
|
updateURL?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|||||||
import type { PluginManifest } from './manifest'
|
import type { PluginManifest } from './manifest'
|
||||||
import type { PluginSettingsSchema, PluginSettingValues } from './settings'
|
import type { PluginSettingsSchema, PluginSettingValues } from './settings'
|
||||||
import type { StatusItem, ToolbarButtonDef } from './status'
|
import type { StatusItem, ToolbarButtonDef } from './status'
|
||||||
|
import type { PluginStorage } from './storage'
|
||||||
/** 插件私有 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 插件日志通道:宿主注入,写入统一日志文件(userData/logs/,带 pluginId 字段)。
|
/** 插件日志通道:宿主注入,写入统一日志文件(userData/logs/,带 pluginId 字段)。
|
||||||
* Agent 自诊断依赖该通道——插件应当把装载/激活/关键决策与失败都记到这里。 */
|
* Agent 自诊断依赖该通道——插件应当把装载/激活/关键决策与失败都记到这里。 */
|
||||||
@@ -50,10 +44,18 @@ export interface HarnessPluginApi {
|
|||||||
/** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */
|
/** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */
|
||||||
pi: ExtensionAPI
|
pi: ExtensionAPI
|
||||||
ui: HarnessUiApi
|
ui: HarnessUiApi
|
||||||
|
/** 私有存储:每插件一个 SQLite 库(KV + 自建表 + 文件目录),API 全 async */
|
||||||
storage: PluginStorage
|
storage: PluginStorage
|
||||||
/** 结构化日志(scope=plugin,自动带 pluginId;error 级别会进日志文件) */
|
/** 结构化日志(scope=plugin,自动带 pluginId;error 级别会进日志文件) */
|
||||||
log: PluginLogger
|
log: PluginLogger
|
||||||
manifest: PluginManifest
|
manifest: PluginManifest
|
||||||
|
/** 注册跨插件服务(按名键控;dispose/重载时宿主自动清空)。
|
||||||
|
* 只有在 manifest 中声明了本插件为依赖的插件才能取用。 */
|
||||||
|
service(name: string, impl: unknown): void
|
||||||
|
/** 取依赖插件的服务对象({ serviceName: impl })。
|
||||||
|
* 未在 dependencies / optionalDependencies 声明 targetId → 抛错(依赖声明即访问授权);
|
||||||
|
* 必需依赖未就绪 → 抛错;可选依赖未就绪 → 返回 undefined。 */
|
||||||
|
requirePlugin(targetId: string): Record<string, unknown> | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 插件工厂:同步或异步返回 dispose */
|
/** 插件工厂:同步或异步返回 dispose */
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 插件存储契约(0.3.0):每插件一个 SQLite 库(userData/plugin-data/<id>/data.db),
|
||||||
|
* 由宿主共享 worker 线程持有连接——API 全 async,永不阻塞主进程。
|
||||||
|
* 本模块只含类型(同时被宿主、渲染层、插件消费),不得引入 node:sqlite。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** SQLite 存储声明中的一列(description 必填——Agent 自我迭代读的就是它) */
|
||||||
|
export interface PluginStorageColumn {
|
||||||
|
name: string
|
||||||
|
/** SQLite 类型:TEXT / INTEGER / REAL / BLOB / NUMERIC */
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件自建表声明 */
|
||||||
|
export interface PluginStorageTable {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
columns: PluginStorageColumn[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 机器可读存储 schema:UI Tooltip 与 Agent 内省同源(plugin:check 强制描述非空)。
|
||||||
|
* kv 表由宿主管理(api.storage.get/set 的底层),这里只要求描述键空间用途。 */
|
||||||
|
export interface PluginStorageSchema {
|
||||||
|
tables: PluginStorageTable[]
|
||||||
|
kv?: { description: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宿主管理的 KV 表(SQLite kv 表键值对;value JSON 编码) */
|
||||||
|
export interface PluginKvStore {
|
||||||
|
get<T>(key: string): Promise<T | undefined>
|
||||||
|
set(key: string, value: unknown): Promise<void>
|
||||||
|
delete(key: string): Promise<void>
|
||||||
|
keys(): Promise<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件自建表的 SQL 入口(worker 线程执行) */
|
||||||
|
export interface PluginDb {
|
||||||
|
/** 多语句 DDL/脚本(无参数) */
|
||||||
|
exec(sql: string): Promise<void>
|
||||||
|
/** 写语句(INSERT/UPDATE/DELETE/DDL,带参数) */
|
||||||
|
run(sql: string, params?: unknown[]): Promise<void>
|
||||||
|
/** 查询(返回全部行) */
|
||||||
|
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>
|
||||||
|
/** 原子批量:worker 端 BEGIN/COMMIT 包裹,任一失败整体回滚 */
|
||||||
|
batch(statements: Array<{ sql: string; params?: unknown[] }>): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件私有存储(api.storage):KV + 自建表 SQLite + 自由文件目录 + schema 声明 */
|
||||||
|
export interface PluginStorage {
|
||||||
|
kv: PluginKvStore
|
||||||
|
db: PluginDb
|
||||||
|
/** 插件数据目录(userData/plugin-data/<id>/,可自由写文件;清除数据时整体删除) */
|
||||||
|
dir(): string
|
||||||
|
/** 声明机器可读存储 schema(每次激活重新声明;使用 db 前必须声明) */
|
||||||
|
declareSchema(schema: PluginStorageSchema): void
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "my-plugin",
|
"id": "my-plugin",
|
||||||
"name": "我的插件",
|
"name": "我的插件",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"minContract": "0.2.0",
|
"minContract": "0.3.0",
|
||||||
"description": "TODO:一句话描述插件做什么",
|
"description": "TODO:一句话描述插件做什么",
|
||||||
"order": 50,
|
"order": 50,
|
||||||
"panel": true,
|
"panel": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user