2 Commits
24 changed files with 1365 additions and 127 deletions
+1 -2
View File
@@ -10,6 +10,5 @@ release/
preview-shots/ preview-shots/
*.tsbuildinfo *.tsbuildinfo
capture-log.txt capture-log.txt
scripts/
preview/ preview/
"tmp-foundation.css" "tmp-foundation.css"
+19 -1
View File
@@ -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 加载,
+10
View File
@@ -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')
+279 -95
View File
@@ -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 => { openPluginDataDir(id: string): boolean {
let kv = this.storage.get(pluginId) const dir = this.pluginDataDir(id)
if (!kv) { if (!dir) return false
kv = new Map() try {
this.storage.set(pluginId, kv) mkdirSync(dir, { recursive: true })
} } catch {
kv.set(key, structuredClone(value)) // 已存在或创建失败都继续(showItemInFolder 对存在路径展示)
this.scheduleStorageSave() }
}, shell.showItemInFolder(dir)
delete: (key: string): void => { return true
this.storage.get(pluginId)?.delete(key) }
this.scheduleStorageSave()
/** 清除插件全部存储数据(连接关闭 + 删数据目录;重装后插件从零开始) */
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) {
p.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
log.error('plugin activation failed', { pluginId: id, err })
this.clearPluginUi(id)
}
} }
private scheduleStorageSave(): void { /** 挂进 AgentService 的 extensionFactories;拓扑波次激活全部已启用插件 */
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) {
log.error('save storage failed', { err })
}
}, 300)
}
// ---- 聚合 pi extension ----
/** 挂进 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) })
}
})
+2
View File
@@ -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">
<PluginSettingsForm {hasForm && (
schema={schema} <PluginSettingsForm
values={settingValues[pid] ?? {}} schema={schema}
onChange={(key, value) => handleSetSettingValue(pid, key, value)} values={settingValues[pid] ?? {}}
onAction={(key) => void window.api.pluginsSettingsAction(pid, key)} onChange={(key, value) => handleSetSettingValue(pid, key, value)}
/> 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>
) )
})()} })()}
+2
View File
@@ -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>
+16 -1
View File
@@ -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
} }
+16 -5
View File
@@ -1,9 +1,9 @@
# 插件系统(harness 插件契约) # 插件系统(harness 插件契约)
> **教程入口**:[docs/plugins/README.md](./plugins/README.md) —— L0 第一个插件 → L1 工具命令 → L2 设置 → L3 UI 槽位 → L4 React 组件 → L5 自诊断;视觉规范 [DESIGN.md](./plugins/DESIGN.md);脚手架 [packages/plugin-template](../packages/plugin-template/)。本文件是契约全集(参考手册)。 > **教程入口**:[docs/plugins/README.md](./plugins/README.md) —— L0 第一个插件 → L1 工具命令 → L2 设置 → L3 UI 槽位 → L4 React 组件 → L5 自诊断 → L8 依赖互调 → L9 存储;视觉规范 [DESIGN.md](./plugins/DESIGN.md);脚手架 [packages/plugin-template](../packages/plugin-template/)。本文件是契约全集(参考手册)。
> >
> 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap) > 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap)
> 契约版本:`HARNESS_CONTRACT_VERSION = 0.2.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载) > 契约版本:`HARNESS_CONTRACT_VERSION = 0.3.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载。0.3.0 新增:依赖声明/服务互调/SQLite 存储)
## 两层能力模型 ## 两层能力模型
@@ -32,6 +32,8 @@
"panel": true, // 在右侧 SidePanel 出现一个 tab "panel": true, // 在右侧 SidePanel 出现一个 tab
"entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry "entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry
"replacesNative": false, "replacesNative": false,
"dependencies": { "fetch-tools": "^1.0.0" }, // 必需依赖:id → semver 范围(* ^ ~ >= 精确);缺失/停用/版本不符 → 激活失败并提示
"optionalDependencies": { "embed-store": "~2.1.0" }, // 可选依赖:缺失只降级(requirePlugin 返回 undefined)
"updateURL": "https://…/index.json" "updateURL": "https://…/index.json"
} }
``` ```
@@ -53,14 +55,23 @@ export default definePlugin((api) => {
api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } }) api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } })
api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页 api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页
// 插件私有 KV(userData/aso-plugin-storage.json,按 pluginId 隔离) // 插件私有存储(SQLite,userData/plugin-data/<id>/data.db;API 全 async,宿主共享 worker 执行)
api.storage.set('lastQuery', 'xxx') await api.storage.declareSchema({ tables: [{ name: 'notes', description: '…', columns: [{ name: 'id', type: 'TEXT', description: '…' }] }] })
await api.storage.kv.set('lastQuery', 'xxx') // KV(宿主管理的 kv 表,JSON 编码)
const rows = await api.storage.db.query('SELECT * FROM notes LIMIT 10')
api.storage.dir() // 自由文件目录
// 跨插件互调(依赖声明即访问授权;拓扑波次保证依赖先激活)
api.service('fetch', (url: string) => fetch(url).then((r) => r.text())) // 暴露服务
const deps = api.requirePlugin('fetch-tools') // 未声明依赖 → 抛错;可选未就绪 → undefined
return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ } return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ }
}) })
``` ```
实现:harness 在 pi `extensionFactories` 挂**一个聚合 extension**,工厂内创建真实 `ExtensionAPI` 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。 实现:harness 在 pi `extensionFactories` 挂**一个聚合 extension**,工厂内创建真实 `ExtensionAPI` 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。激活顺序 = **必需依赖图的拓扑波次**(依赖先、依赖者后,同波并行;环整环报错;异步工厂限时 10s)。
依赖与存储细则:见 [L8 依赖与互调](./plugins/10-dependencies.md)、[L9 存储](./plugins/11-storage.md)。
## 插件页(iframe)契约(`@aso/plugin-sdk/client`) ## 插件页(iframe)契约(`@aso/plugin-sdk/client`)
+4
View File
@@ -18,6 +18,10 @@ import { readFile } from 'node:fs/promises'
const text = await readFile(path, 'utf8') const text = await readFile(path, 'utf8')
``` ```
**存储也不例外**:`api.storage.*`(SQLite)在宿主的共享 worker 线程里执行,
主进程只做异步 RPC——大查询不会卡聊天流(见 [L9](./11-storage.md))。
自己开的文件句柄同理:全 async。
### 2. 激活限时 10 秒(宿主强制) ### 2. 激活限时 10 秒(宿主强制)
插件工厂若返回 Promise,宿主限时 10s;超时或 reject 即把插件标记为失败 插件工厂若返回 Promise,宿主限时 10s;超时或 reject 即把插件标记为失败
+28 -5
View File
@@ -52,7 +52,30 @@
| 设置页按钮点了没反应 | 没挂 `onSettingsAction` | 按 key 订阅动作回调 | | 设置页按钮点了没反应 | 没挂 `onSettingsAction` | 按 key 订阅动作回调 |
| 左侧导航「Agent 能力」没有插件入口 | `settingsNav` 未开启 | 插件行 → 入口开关打开 | | 左侧导航「Agent 能力」没有插件入口 | `settingsNav` 未开启 | 插件行 → 入口开关打开 |
## 6. 样式不对 ## 6. 依赖与互调(契约 0.3.0)
| 症状 | 原因 | 修复 |
|---|---|---|
| 行红字「缺少依赖插件 x(^1.0.0),请安装后重载」 | 必需依赖未安装 | 把依赖插件放入任一插件目录,点「重载」;宿主不自动安装 |
| 行红字「依赖插件 x 已停用,请先启用」 | 必需依赖被禁用 | 到该依赖插件行打开「启动」;启用后本插件自动恢复 |
| 行红字「依赖插件 x 需要 ^2.0,当前 v1.4.0」 | 版本不满足范围 | 升级依赖插件,或放宽自己 manifest 的范围 |
| 行红字「循环依赖: a → b → a」 | 必需依赖成环 | 解环(改为 optionalDependencies 或重构边界) |
| `requirePlugin` 抛「未声明对 x 的依赖」 | 未在 dependencies/optionalDependencies 声明 | 依赖声明即访问授权——manifest 里加上,并满足其 `minContract` |
| `requirePlugin` 返回 undefined(可选依赖) | 可选依赖缺失/停用/未就绪 | 预期行为;走降级路径 |
| 改了依赖方代码,依赖方行为没变 | 没重载 | 重载是宿主级动作,点同一个「重载」即可 |
## 7. 存储(SQLite)
| 症状 | 原因 | 修复 |
|---|---|---|
| `plugin:check` 报「使用了 api.storage.db 但未调用 declareSchema」 | 用了 SQLite 却没声明机器可读 schema | 激活期调用 `api.storage.declareSchema`,表/字段 description 写清楚(Agent 内省与设置页展示同源) |
| `api.storage.*` 报「存储操作超时(…5000ms)」 | worker 挂死或查询过重 | 检查 SQL 是否全表扫描/锁表;重启应用可恢复 worker |
| 报「database is locked」 | 跨进程/工具直接打开了同一个 data.db | 用宿主 API 访问,不要同时用外部 sqlite 工具长连接 |
| KV 读回来的值不对 | value 不可 JSON 序列化(Date/Map) | 存前转 JSON 兼容类型;大二进制放文件、路径存 KV |
| 想彻底重来 | — | 设置 → 插件 → 二级页「存储区」→ 清除数据(二次确认,整目录删除) |
| 表结构要改 | — | `PRAGMA user_version` 自管迁移(见 [L9](./11-storage.md) §4) |
## 8. 样式不对
| 症状 | 原因 | 修复 | | 症状 | 原因 | 修复 |
|---|---|---| |---|---|---|
@@ -62,14 +85,14 @@
| 图标是拼图(fallback) | manifest.icon 不在 lucide 白名单 | 改成白名单内的 lucide 名(DESIGN.md §3;`plugin:check` 会 warn) | | 图标是拼图(fallback) | manifest.icon 不在 lucide 白名单 | 改成白名单内的 lucide 名(DESIGN.md §3;`plugin:check` 会 warn) |
| 按钮不是胶囊形 / 圆角不对 | 手写 rounded-control 当按钮 | renderer 用组件库 `Button`(pill 体系);圆角体系 chip6/control8/card10/window14 | | 按钮不是胶囊形 / 圆角不对 | 手写 rounded-control 当按钮 | renderer 用组件库 `Button`(pill 体系);圆角体系 chip6/control8/card10/window14 |
## 7. 检验器对照 ## 9. 检验器对照
`pnpm plugin:check <目录>` 输出的每条 error/warn 都带修复指引;完整规则表见 [L7](./08-performance.md)。速记: `pnpm plugin:check <目录>` 输出的每条 error/warn 都带修复指引;完整规则表见 [L7](./08-performance.md)。速记:
- error = 装不了或必然出问题(同步 IO、console.*、React 副本、hex、超尺寸、契约非法) - error = 装不了或必然出问题(同步 IO、console.*、React 副本、hex、超尺寸、契约非法、依赖声明非法、用 db 未声明 schema、requirePlugin 未声明依赖)
- warn = 应当修(缺 minContract、疑似 React 副本、三态缺失、阻塞对话框、icon 不在白名单、产物未构建) - warn = 应当修(缺 minContract、疑似 React 副本、三态缺失、阻塞对话框、icon 不在白名单、产物未构建、声明依赖但 minContract 过低)
## 8. 还不行? ## 10. 还不行?
1. `pnpm plugin:check <目录>` → 0 error 1. `pnpm plugin:check <目录>` → 0 error
2. 重载插件 → 看 `userData/logs/main-*.log` 里 `plugin activated pluginId=<id>` 之后你自己的 `api.log` 输出 2. 重载插件 → 看 `userData/logs/main-*.log` 里 `plugin activated pluginId=<id>` 之后你自己的 `api.log` 输出
+97
View File
@@ -0,0 +1,97 @@
# L8 插件依赖与服务互调
> 插件不是孤岛:A 依赖 B/C 时,声明、激活顺序、可用性判定、调用规范由宿主统一保证。
> 契约 0.3.0 起;全部机制在 `dependencies` / `optionalDependencies` + `api.service` / `api.requirePlugin` 四个名字里。
## 1. 声明依赖(manifest.json)
```json
{
"id": "wiki-assistant",
"version": "1.2.0",
"minContract": "0.3.0",
"dependencies": {
"fetch-tools": "^1.0.0",
"browser": "*"
},
"optionalDependencies": {
"embed-store": "~2.1.0"
}
}
```
- 值是 **semver 范围**,支持五种写法:`*`(任意)、`^1.2.0`(同主版本)、`~1.2.0`(同次版本)、`>=1.2.0`(下限)、`1.2.0`(精确)。
- `dependencies` 是**必需依赖**:缺失 / 已停用 / 版本不符 / 自身装载失败 → 本插件激活失败。
- `optionalDependencies` 是**可选依赖**:不就绪只降级,`api.requirePlugin(id)` 返回 `undefined`。
- **依赖声明即访问授权**:`requirePlugin` 只对声明过的 id 放行,未声明直接抛错。
## 2. 运行时判定(软绑定,无强行绑定)
宿主在每次激活轮(启停/重载/重建运行时)做三步:
1. **静态预检**:逐项检查必需依赖——
| 状态 | 判定 | 插件行展示的错误 |
|---|---|---|
| missing | resolved 表里没有该 id | `缺少依赖插件 fetch-tools(^1.0.0),请安装后重载` |
| disabled | 在禁用列表里 | `依赖插件 browser 已停用,请先启用` |
| version | 不满足范围 | `依赖插件 fetch-tools 需要 ^1.0.0,当前 v0.9.0` |
| error | 依赖自身装载失败 | `依赖插件 fetch-tools 装载失败,无法激活` |
2. **环检测**:必需依赖图存在环 → 整环不激活,每个成员报 `循环依赖: a → b → a`。
3. **拓扑波次激活**:依赖先激活、依赖者后激活,无依赖关系的插件**同波并行**(异步工厂限时 10s,挂死不拖垮后续波次)。依赖激活失败时,依赖方报 `依赖插件 x 未能激活`。
行为语义(有意为之):
- A 声明依赖 B:**禁用 B → A 激活失败**(错误栏写明原因);**重新启用 B → A 自动恢复**(重建运行时天然重放)。
- **禁用 A 不影响 B**——依赖不级联、不偷改用户的选择。
- **不自动安装**:缺什么报什么,装不装由用户决定(错误文案就是行动指引)。
## 3. 服务互调(api.service / api.requirePlugin)
所有插件的 main 工厂运行在同一 pi 上下文,互调是**同进程直调**(无 IPC、无序列化),宿主只提供注册表与门控:
```ts
// fetch-tools 的 main.mjs —— 暴露服务
export default definePlugin((api) => {
api.service('fetch', async (url: string, init?: RequestInit) => {
const res = await fetch(url, init)
return res.text()
})
})
// wiki-assistant 的 main.mjs —— 消费服务
export default definePlugin(async (api) => {
const fetchTools = api.requirePlugin('fetch-tools') as
| { fetch: (url: string, init?: RequestInit) => Promise<string> }
| undefined
if (!fetchTools) {
// 可选依赖未就绪:降级路径(必需依赖不会走到这里——激活前置检已拦)
}
api.pi.registerTool({
name: 'wiki_lookup',
// ...
execute: async (args) => {
// 服务方法约定 async;调用方自行限时(与宿主性能护栏同构)
const body = await withTimeout(fetchTools!.fetch(args.url), 8_000, 'wiki_lookup')
// ...
}
})
})
```
规则:
- `api.service(name, impl)`:按名键控;重载/停用时随注册表清空,重激活即重新注册。
- `api.requirePlugin(id)`:返回 `{ serviceName: impl }`;未声明 → 抛错;必需依赖未就绪 → 抛错;可选未就绪 → `undefined`。
- **服务方法约定全 async**,调用方用 `withTimeout` 限时、必要时 `withRetry`(见 L7)——被调插件慢不能变成调用方卡死。
- 依赖可以指向内置插件(如 `browser`),内置与外置同一注册表。
## 4. UI 展示
- 管理页插件行名称旁出现**链接图标**,悬停 Tooltip 列出每个依赖:id / 范围 / 必需或可选 / 实时状态(可用·绿 / 未安装·橙 / 已停用·橙 / 版本不符·橙 / 装载失败·红)。
- 依赖问题的详细错误在该插件行红字与二级页展示;「去启用」到对应插件的行上操作。
## 5. 检验器
`pnpm plugin:check` 会校验:依赖 id 合法、范围语法、不能依赖自身、不能同时出现在必需与可选、声明了依赖但 `minContract < 0.3.0`(旧宿主会静默忽略声明)、调用了 `requirePlugin` 却未声明依赖。
+88
View File
@@ -0,0 +1,88 @@
# L9 插件存储(SQLite)
> 每个插件一块**私有**持久化空间:一个 SQLite 库 + 一个自由文件目录。
> KV 简单状态、SQL 结构化数据、文件随便放;schema 必须机器可读声明——它是 Agent 自我迭代与 UI 展示的同一份事实源。
## 1. 空间布局
```
userData/plugin-data/<plugin-id>/
data.db ← SQLite 库(WAL 模式):kv 表(宿主管理)+ 插件自建表
data.db-wal ← WAL 伴生文件
<其余自由文件> ← api.storage.dir() 指向这里,插件自行读写
```
- **每插件一库**(不是共享大库):导出/清除/损坏恢复都以插件为单位,跨插件天然隔离。
- 卸载/移除插件**不删数据**(重装即恢复);删除只在设置页点「清除数据」(二次确认)时发生。
## 2. API(全部 async——底层在宿主共享 worker 线程执行,永不阻塞主进程)
```ts
export default definePlugin(async (api) => {
// ① 声明机器可读 schema(使用 db 前必须声明;plugin:check 强制)
api.storage.declareSchema({
kv: { description: '会话级偏好:ui.<key> = 用户界面偏好' },
tables: [
{
name: 'notes',
description: '用户笔记',
columns: [
{ name: 'id', type: 'TEXT', description: '主键,ulid' },
{ name: 'title', type: 'TEXT', description: '标题,非空' },
{ name: 'created_at', type: 'INTEGER', description: '创建时间,Unix 毫秒' }
]
}
]
})
// ② KV:宿主管理的 kv 表(value 走 JSON 编码,适合设置类小状态)
const last = await api.storage.kv.get<string>('ui.lastQuery')
await api.storage.kv.set('ui.lastQuery', 'postgres')
await api.storage.kv.delete('ui.lastQuery')
const keys = await api.storage.kv.keys()
// ③ 自建表:SQL 自由发挥
await api.storage.db.exec('CREATE TABLE IF NOT EXISTS notes (...)') // DDL,激活期执行
await api.storage.db.run('INSERT INTO notes (id, title) VALUES (?, ?)', ['n1', '标题'])
const rows = await api.storage.db.query('SELECT * FROM notes WHERE title LIKE ?', ['%标%'])
// 原子批量:worker 端 BEGIN/COMMIT,任一失败整体回滚
await api.storage.db.batch([
{ sql: 'INSERT INTO notes (id, title) VALUES (?, ?)', params: ['n2', 'a'] },
{ sql: 'UPDATE notes SET title = ? WHERE id = ?', params: ['b', 'n2'] }
])
// ④ 自由文件(日志快照、导出等)
const dir = api.storage.dir() // userData/plugin-data/<id>/
})
```
## 3. schema 声明为什么是硬性要求
插件会被 **Agent 自我迭代**。迭代前 Agent 需要知道「库里有什么」:
- `declareSchema` 的内容随 `plugins:list` 下发——管理页「存储区」卡片与(未来的)Agent 内省工具读的是**同一份**数据;
- 每张表、每个字段的 `description` 必填,`plugin:check` 会拒绝「用了 db 却不声明 schema」的插件;
- 写给 Agent 读的 description 请具体(「创建时间,Unix 毫秒」而不是「时间」)。
## 4. 迁移(表结构变更)
宿主不解释 schema,迁移由插件自管,惯用法:
```ts
const row = await api.storage.db.query('PRAGMA user_version')
const v = Number(row[0]?.user_version ?? 0)
if (v < 1) {
await api.storage.db.exec('ALTER TABLE notes ADD COLUMN tag TEXT')
await api.storage.db.exec('PRAGMA user_version = 1')
}
```
## 5. 设置页管理
- 二级页「存储区」卡片:schema 表格化展示(表/列/类型/描述)+ **数据目录**(资源管理器打开)+ **清除数据**(二次确认,连接关闭后整目录删除)。
## 6. 边界
- 存储只在 **main 层**提供;renderer 组件 / iframe 页需要数据时,让 main 侧经状态胶囊/设置值转发(受控 IPC 通道属后续契约)。
- KV 的 value 必须可 JSON 序列化(Date/Map 请先转换);大二进制放文件,路径存 KV。
- 跨插件**不直读对方库**:需要别人的数据 = 声明依赖 + 走对方服务(见 L8)。
+2
View File
@@ -15,6 +15,8 @@
| [L5 自诊断](./06-self-diagnosis.md) | 日志规范、排障闭环、Agent 自迭代约定 | 可被 Agent 维护的插件 | | [L5 自诊断](./06-self-diagnosis.md) | 日志规范、排障闭环、Agent 自迭代约定 | 可被 Agent 维护的插件 |
| [L6 公共组件库](./07-ui-components.md) | @aso/plugin-sdk/ui:统一三态(等待/失败/空)与基础控件 | 与宿主零差异的 UI | | [L6 公共组件库](./07-ui-components.md) | @aso/plugin-sdk/ui:统一三态(等待/失败/空)与基础控件 | 与宿主零差异的 UI |
| [L7 性能与检验](./08-performance.md) | 主线程不阻塞、超时重试、plugin:check 机器关卡 | 可发布、不卡宿主的插件 | | [L7 性能与检验](./08-performance.md) | 主线程不阻塞、超时重试、plugin:check 机器关卡 | 可发布、不卡宿主的插件 |
| [L8 依赖与互调](./10-dependencies.md) | dependencies 声明、拓扑激活、api.service/requirePlugin | 可组合、可协作的插件 |
| [L9 存储](./11-storage.md) | 每插件 SQLite 库 + KV + schema 声明 + 数据管理 | 有持久化、可被 Agent 迭代的插件 |
> **排障中?** 直接跳 [常见错误速查](./09-troubleshooting.md)——症状 → 原因 → 修复三段式速查表。 > **排障中?** 直接跳 [常见错误速查](./09-troubleshooting.md)——症状 → 原因 → 修复三段式速查表。
+1 -1
View File
@@ -12,7 +12,7 @@
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) | | v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 | | v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 | | v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、状态胶囊详情面板(ZCode Git 工具式:头部+分组+行尾彩色数值,B3 浮动态并入顶栏胶囊)、图标统一 lucide 白名单、面板平铺标签条 | | v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data/<id>/data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、状态胶囊详情面板(ZCode Git 工具式:头部+分组+行尾彩色数值,B3 浮动态并入顶栏胶囊)、图标统一 lucide 白名单、面板平铺标签条 |
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 | | 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
--- ---
+1
View File
@@ -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'
+5
View File
@@ -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
} }
+9 -7
View File
@@ -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 */
+57
View File
@@ -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
}
+1 -1
View File
@@ -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,
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* 插件检验器(plugin:check)——性能与样式标准的机器关卡。
*
* 用法:
* pnpm plugin:check <插件目录> [更多目录...]
* node scripts/check-plugin.mjs <插件目录>
*
* 检查项(error = 必须修,exit 1;warn = 应当修):
* 【契约】manifest.json 可解析;id/name/version(semver)/minContract 齐全;
* minContract ≤ 宿主契约版本(从 plugin-host.ts 源码提取,单一事实源);
* entry.main / entry.renderer / entry.page 指向的文件存在
* 【性能】renderer.mjs ≤ 8MB(宿主 readRenderer 上限)、main.mjs ≤ 2MB;
* main.mjs 禁同步 IO(*Sync / execSync / spawnSync——阻塞主进程事件循环);
* 任何源码禁 alert/confirm/prompt(阻塞渲染进程的同步对话框)
* 【单实例】renderer.mjs 必须引用 __ASO_PLUGIN_HOST__(React shim);>300KB 警告
* 疑似打包了自带 React 副本
* 【样式】禁硬编码 hex 色值(#fff/#ffffff 除外——accent 按钮白字),一切颜色走 token
* 【三态】注册了 view/panel 的 renderer 应使用 @aso/plugin-sdk/ui 的
* LoadingState/ErrorState/EmptyState/Spinner(等待/失败/空态统一呈现)
* 【日志】main.mjs 禁 console.*(统一走 api.log,见 docs/plugins/06-self-diagnosis.md)
*
* 规则细节见 docs/plugins/08-performance.md;样式规范见 docs/plugins/DESIGN.md。
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..')
const MAX_RENDERER_BYTES = 8 * 1024 * 1024
const MAX_MAIN_BYTES = 2 * 1024 * 1024
const REACT_COPY_SUSPECT_BYTES = 300 * 1024
/** 从宿主源码提取契约版本(避免检验器里出现第二份常量) */
function harnessContractVersion() {
const src = join(ROOT, 'apps', 'desktop', 'src', 'main', 'plugins', 'plugin-host.ts')
if (!existsSync(src)) return null
const m = /HARNESS_CONTRACT_VERSION\s*=\s*'(\d+\.\d+\.\d+)'/.exec(readFileSync(src, 'utf8'))
return m ? m[1] : null
}
const contract = harnessContractVersion()
/** lucide 图标白名单:从宿主 plugin-icons.tsx 提取(单一事实源),校验 manifest.icon 用 */
function pluginIconNames() {
const src = join(ROOT, 'apps', 'desktop', 'src', 'renderer', 'src', 'plugins', 'plugin-icons.tsx')
if (!existsSync(src)) return null
const names = [...readFileSync(src, 'utf8').matchAll(/^ ([A-Za-z0-9]+): Lu[A-Za-z0-9]+,$/gm)].map((m) => m[1])
return names.length ? new Set(names) : null
}
function parseSemver(v) {
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim())
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
}
function compareSemver(a, b) {
const pa = parseSemver(a) ?? [0, 0, 0]
const pb = parseSemver(b) ?? [0, 0, 0]
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1
}
return 0
}
/** 递归收集插件目录下的源文件(跳过 node_modules/.git/out) */
function collectFiles(dir, base = dir, acc = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
const st = statSync(p)
if (st.isDirectory()) {
if (name === 'node_modules' || name === '.git' || name === 'out') continue
collectFiles(p, base, acc)
} else if (/\.(mjs|js|jsx|ts|tsx|html|css)$/.test(name)) {
acc.push(relative(base, p).split(sep).join('/'))
}
}
return acc
}
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g
const HEX_ALLOW = new Set(['#fff', '#ffffff'])
const SYNC_IO = /\b(readFileSync|writeFileSync|appendFileSync|copyFileSync|mkdirSync|mkdtempSync|readdirSync|statSync|lstatSync|existsSync|accessSync|truncateSync|chmodSync|chownSync|unlinkSync|rmSync|rmdirSync|renameSync|cpSync|execSync|spawnSync|execFileSync)\s*\(/g
const BLOCKING_DIALOG = /\b(alert|confirm|prompt)\s*\(/g
const CONSOLE = /\bconsole\.(log|info|warn|error|debug|trace)\s*\(/g
const STATE_COMPONENTS = /(LoadingState|ErrorState|EmptyState|Spinner)/
const UI_KIT_IMPORT = /@aso\/plugin-sdk\/ui/
/** 检查单个插件目录,返回 { errors: string[], warns: string[] } */
function checkPlugin(dir) {
const errors = []
const warns = []
const err = (msg) => errors.push(msg)
const warn = (msg) => warns.push(msg)
if (!existsSync(dir)) {
err(`目录不存在: ${dir}`)
return { errors, warns }
}
// ---- 契约 ----
const manifestPath = join(dir, 'manifest.json')
if (!existsSync(manifestPath)) {
err('缺 manifest.json')
return { errors, warns }
}
let manifest
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
} catch (e) {
err(`manifest.json 解析失败: ${e.message}`)
return { errors, warns }
}
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(String(manifest.id ?? ''))) err(`manifest.id 非法: ${JSON.stringify(manifest.id)}`)
if (!manifest.name) err('缺 manifest.name')
if (!parseSemver(manifest.version)) err(`manifest.version 非法 semver: ${JSON.stringify(manifest.version)}`)
// 图标规范:统一 lucide(内联 svg 仅旧兼容;未知名宿主回落拼图图标)
if (typeof manifest.icon === 'string' && manifest.icon.trim() && !manifest.icon.trim().startsWith('<svg')) {
const names = pluginIconNames()
if (names) {
const pascal = manifest.icon
.trim()
.replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase())
if (!names.has(pascal)) {
warn(`manifest.icon "${manifest.icon.trim()}" 不在 lucide 白名单(宿主将回落拼图图标);名单见 docs/plugins/DESIGN.md §3`)
}
}
}
if (!manifest.minContract) {
warn('缺 manifest.minContract(建议声明,便于契约升级时感知)')
} else if (contract && compareSemver(manifest.minContract, contract) > 0) {
err(`manifest.minContract ${manifest.minContract} 高于宿主契约 ${contract}(插件比宿主新,无法装载)`)
}
// ---- 依赖声明(dependencies / optionalDependencies:插件 id → semver 范围) ----
const DEP_RANGE = /^(\*|(>=|\^|~)?\d+\.\d+\.\d+)$/
const ID_OK = /^[a-z][a-z0-9-]*$/
const checkDeps = (field, primary) => {
const raw = manifest[field]
if (raw === undefined) return
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
err(`manifest.${field} 必须是对象(插件 id → semver 范围)`)
return
}
for (const [id, range] of Object.entries(raw)) {
if (!ID_OK.test(id)) err(`manifest.${field}.${id}: 插件 id 非法(小写字母开头,字母/数字/连字符)`)
if (id === manifest.id) err(`manifest.${field}.${id}: 不能依赖自身`)
if (typeof range !== 'string' || !DEP_RANGE.test(range.trim())) {
err(`manifest.${field}.${id}: 范围「${JSON.stringify(range)}」非法——支持 * / ^x.y.z / ~x.y.z / >=x.y.z / 精确 x.y.z`)
}
if (primary && manifest.optionalDependencies && Object.hasOwn(manifest.optionalDependencies, id)) {
err(`依赖 ${id} 同时出现在 dependencies 与 optionalDependencies`)
}
}
if (Object.keys(raw).length > 0 && manifest.minContract && contract && compareSemver(manifest.minContract, '0.3.0') < 0) {
warn(`声明了 ${field} 但 minContract ${manifest.minContract} < 0.3.0——旧宿主会静默忽略依赖声明,建议提升 minContract`)
}
}
checkDeps('dependencies', true)
checkDeps('optionalDependencies', false)
const entry = manifest.entry ?? {}
const mainFile = entry.main ?? 'main.mjs'
if (entry.main && !existsSync(join(dir, entry.main))) err(`entry.main 不存在: ${entry.main}`)
if (entry.renderer && !existsSync(join(dir, entry.renderer))) {
// 源码形态(src/renderer.tsx 等待构建)算 warn;源码也没有才是 error
const hasRendererSrc = ['tsx', 'ts', 'jsx', 'js'].some((ext) => existsSync(join(dir, 'src', `renderer.${ext}`)))
if (hasRendererSrc) {
warn(`entry.renderer 产物缺失(${entry.renderer})——按模板 README 的 esbuild 配方构建后放回`)
} else {
err(`entry.renderer 不存在: ${entry.renderer}`)
}
}
if (entry.page && !existsSync(join(dir, entry.page))) err(`entry.page 不存在: ${entry.page}`)
// ---- 尺寸 ----
const sizeOf = (f) => {
const p = join(dir, f)
return existsSync(p) ? statSync(p).size : -1
}
if (sizeOf(mainFile) > MAX_MAIN_BYTES) err(`${mainFile} 超过 2MB 上限`)
const rendererFile = entry.renderer
if (rendererFile && sizeOf(rendererFile) > MAX_RENDERER_BYTES) {
err(`${rendererFile} 超过 8MB 上限(宿主 readRenderer 拒绝装载)`)
}
// ---- main.mjs:同步 IO + 日志 ----
if (!entry.main || existsSync(join(dir, mainFile))) {
const mainSrc = existsSync(join(dir, mainFile)) ? readFileSync(join(dir, mainFile), 'utf8') : ''
if (mainSrc) {
for (const m of mainSrc.matchAll(SYNC_IO)) {
err(`${mainFile}: 同步 IO「${m[1]}(」阻塞主进程事件循环——改用 node:fs/promises 的异步版(性能铁律见 08-performance.md)`)
}
for (const m of mainSrc.matchAll(CONSOLE)) {
const level = m[1] === 'log' || m[1] === 'trace' ? 'debug' : m[1]
err(`${mainFile}: console.${m[1]}( 绕过统一日志——改用 api.log.${level}(见 L5 自诊断)`)
}
// 存储契约:用 SQLite 必须声明机器可读 schema(Agent 自我迭代与设置页展示同源)
if (/storage\.db\.(exec|run|query|batch)\s*\(/.test(mainSrc) && !/declareSchema\s*\(/.test(mainSrc)) {
err(`${mainFile}: 使用了 api.storage.db 但未调用 api.storage.declareSchema——每张表/每个字段的 description 是 Agent 内省与 UI 展示的依据(契约 0.3.0)`)
}
if (/requirePlugin\s*\(/.test(mainSrc) && !manifest.dependencies && !manifest.optionalDependencies) {
err(`${mainFile}: 调用了 api.requirePlugin 但 manifest 未声明 dependencies/optionalDependencies——依赖声明即访问授权,未声明运行时直接抛错`)
}
}
}
// ---- renderer:单实例 + 三态 ----
if (rendererFile && existsSync(join(dir, rendererFile))) {
const src = readFileSync(join(dir, rendererFile), 'utf8')
if (!src.includes('__ASO_PLUGIN_HOST__')) {
err(`${rendererFile}: 未引用 __ASO_PLUGIN_HOST__——React 必须经宿主 shim 单实例(esbuild --alias:react 配方见 L4),自带副本会导致 hooks 失效`)
} else if (statSync(join(dir, rendererFile)).size > REACT_COPY_SUSPECT_BYTES) {
warn(`${rendererFile} 超过 300KB:疑似打包了自带 React 副本或未裁剪依赖,请核对 esbuild 配方`)
}
const registersUi = /register(View|Panel)\s*\(/.test(src)
const usesStates = STATE_COMPONENTS.test(src) || UI_KIT_IMPORT.test(src)
if (registersUi && !usesStates) {
warn(`${rendererFile}: 注册了 view/panel 但未使用公共三态组件(LoadingState/ErrorState/EmptyState)——异步区块请统一等待/失败呈现(见 07-ui-components.md)`)
}
}
// ---- 全源码:硬编码色值 + 阻塞对话框 ----
for (const rel of collectFiles(dir)) {
const src = readFileSync(join(dir, rel), 'utf8')
src.split('\n').forEach((line, idx) => {
for (const m of line.matchAll(HEX_COLOR)) {
if (HEX_ALLOW.has(m[0].toLowerCase())) continue
err(`${rel}:${idx + 1}: 硬编码色值 ${m[0]}——只允许 token(DESIGN.md 铁律 1;#fff 白字除外)`)
}
for (const m of line.matchAll(BLOCKING_DIALOG)) {
warn(`${rel}:${idx + 1}: ${m[1]}( 是阻塞式同步对话框——请用组件库 Button + ErrorState/EmptyState 呈现`)
}
})
}
return { errors, warns }
}
// ---- CLI ----
const args = process.argv.slice(2).filter((a) => a && !a.startsWith('-'))
if (args.length === 0) {
console.error('用法: pnpm plugin:check <插件目录> [更多目录...]')
process.exit(2)
}
let totalErr = 0
let totalWarn = 0
for (const dir of args) {
const { errors, warns } = checkPlugin(dir)
totalErr += errors.length
totalWarn += warns.length
console.log(`\n== ${dir}`)
for (const e of errors) console.log(` [error] ${e}`)
for (const w of warns) console.log(` [warn ] ${w}`)
if (errors.length === 0 && warns.length === 0) console.log(' 全部检查通过')
}
console.log(`\n汇总: ${totalErr} error, ${totalWarn} warn${contract ? `(宿主契约 ${contract})` : ''}`)
process.exit(totalErr > 0 ? 1 : 0)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* 拉取 beautifului.dev 的 shadcn registry 源到 docs/ui-ref/beautifului/。
* 组件清单来自站点 01-21 节;slug 与锚点基本一致(个别做了别名兜底)。
* 用法:node scripts/fetch-beautifului-registry.mjs
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const SLUGS = [
'loading-state',
'thinking',
'thinking-state',
'streaming-text',
'approval-card',
'tool-chips',
'task-rows',
'chat',
'chat-composer',
'prompt-bar',
'recommendation-card',
'context-cards',
'diff-table',
'records-table',
'filter-table',
'sidebar-nav',
'search',
'flowchart',
'insight-cards',
'code-block',
'fine-tune-card',
'selection-actions',
'agent-screen',
'foundation',
'button',
'glide-menu',
'entity-chip',
'value-pill',
'shimmer',
'stream-text'
]
const OUT = join(process.cwd(), 'docs', 'ui-ref', 'beautifului')
mkdirSync(OUT, { recursive: true })
for (const slug of SLUGS) {
const url = `https://www.beautifului.dev/r/${slug}.json`
try {
const res = await fetch(url)
if (!res.ok) {
console.log(`[miss ] ${slug} (${res.status})`)
continue
}
const json = await res.json()
writeFileSync(join(OUT, `${slug}.json`), JSON.stringify(json, null, 2), 'utf8')
const files = (json.files ?? []).map((f) => f.path).join(', ')
console.log(`[ok ] ${slug} files: ${files || '(none)'}`)
} catch (err) {
console.log(`[error] ${slug}: ${err.message}`)
}
}