docs(plugins): 依赖与存储文档(L8/L9)+ 检验器入库并升级契约 0.3.0 规则
This commit is contained in:
@@ -10,6 +10,5 @@ release/
|
||||
preview-shots/
|
||||
*.tsbuildinfo
|
||||
capture-log.txt
|
||||
scripts/
|
||||
preview/
|
||||
"tmp-foundation.css"
|
||||
|
||||
+16
-5
@@ -1,9 +1,9 @@
|
||||
# 插件系统(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)
|
||||
> 契约版本:`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
|
||||
"entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry
|
||||
"replacesNative": false,
|
||||
"dependencies": { "fetch-tools": "^1.0.0" }, // 必需依赖:id → semver 范围(* ^ ~ >= 精确);缺失/停用/版本不符 → 激活失败并提示
|
||||
"optionalDependencies": { "embed-store": "~2.1.0" }, // 可选依赖:缺失只降级(requirePlugin 返回 undefined)
|
||||
"updateURL": "https://…/index.json"
|
||||
}
|
||||
```
|
||||
@@ -53,14 +55,23 @@ export default definePlugin((api) => {
|
||||
api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } })
|
||||
api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页
|
||||
|
||||
// 插件私有 KV(userData/aso-plugin-storage.json,按 pluginId 隔离)
|
||||
api.storage.set('lastQuery', 'xxx')
|
||||
// 插件私有存储(SQLite,userData/plugin-data/<id>/data.db;API 全 async,宿主共享 worker 执行)
|
||||
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 / 禁用 / 重载时调用 */ }
|
||||
})
|
||||
```
|
||||
|
||||
实现: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`)
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ import { readFile } from 'node:fs/promises'
|
||||
const text = await readFile(path, 'utf8')
|
||||
```
|
||||
|
||||
**存储也不例外**:`api.storage.*`(SQLite)在宿主的共享 worker 线程里执行,
|
||||
主进程只做异步 RPC——大查询不会卡聊天流(见 [L9](./11-storage.md))。
|
||||
自己开的文件句柄同理:全 async。
|
||||
|
||||
### 2. 激活限时 10 秒(宿主强制)
|
||||
|
||||
插件工厂若返回 Promise,宿主限时 10s;超时或 reject 即把插件标记为失败
|
||||
|
||||
@@ -52,7 +52,30 @@
|
||||
| 设置页按钮点了没反应 | 没挂 `onSettingsAction` | 按 key 订阅动作回调 |
|
||||
| 左侧导航「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) |
|
||||
| 按钮不是胶囊形 / 圆角不对 | 手写 rounded-control 当按钮 | renderer 用组件库 `Button`(pill 体系);圆角体系 chip6/control8/card10/window14 |
|
||||
|
||||
## 7. 检验器对照
|
||||
## 9. 检验器对照
|
||||
|
||||
`pnpm plugin:check <目录>` 输出的每条 error/warn 都带修复指引;完整规则表见 [L7](./08-performance.md)。速记:
|
||||
|
||||
- error = 装不了或必然出问题(同步 IO、console.*、React 副本、hex、超尺寸、契约非法)
|
||||
- warn = 应当修(缺 minContract、疑似 React 副本、三态缺失、阻塞对话框、icon 不在白名单、产物未构建)
|
||||
- error = 装不了或必然出问题(同步 IO、console.*、React 副本、hex、超尺寸、契约非法、依赖声明非法、用 db 未声明 schema、requirePlugin 未声明依赖)
|
||||
- warn = 应当修(缺 minContract、疑似 React 副本、三态缺失、阻塞对话框、icon 不在白名单、产物未构建、声明依赖但 minContract 过低)
|
||||
|
||||
## 8. 还不行?
|
||||
## 10. 还不行?
|
||||
|
||||
1. `pnpm plugin:check <目录>` → 0 error
|
||||
2. 重载插件 → 看 `userData/logs/main-*.log` 里 `plugin activated pluginId=<id>` 之后你自己的 `api.log` 输出
|
||||
|
||||
@@ -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` 却未声明依赖。
|
||||
@@ -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)。
|
||||
@@ -15,6 +15,8 @@
|
||||
| [L5 自诊断](./06-self-diagnosis.md) | 日志规范、排障闭环、Agent 自迭代约定 | 可被 Agent 维护的插件 |
|
||||
| [L6 公共组件库](./07-ui-components.md) | @aso/plugin-sdk/ui:统一三态(等待/失败/空)与基础控件 | 与宿主零差异的 UI |
|
||||
| [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)——症状 → 原因 → 修复三段式速查表。
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
||||
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
||||
| 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 子进程隔离模式 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user