From 9b451c8c25c0fc249ced390dccf019f6a391c265 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Fri, 11 Sep 2026 15:00:34 +0800 Subject: [PATCH] =?UTF-8?q?docs(plugins):=20=E4=BE=9D=E8=B5=96=E4=B8=8E?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E6=96=87=E6=A1=A3=EF=BC=88L8/L9=EF=BC=89+=20?= =?UTF-8?q?=E6=A3=80=E9=AA=8C=E5=99=A8=E5=85=A5=E5=BA=93=E5=B9=B6=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E5=A5=91=E7=BA=A6=200.3.0=20=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- docs/plugins.md | 21 +- docs/plugins/08-performance.md | 4 + docs/plugins/09-troubleshooting.md | 33 +++- docs/plugins/10-dependencies.md | 97 +++++++++ docs/plugins/11-storage.md | 88 +++++++++ docs/plugins/README.md | 2 + docs/roadmap.md | 2 +- scripts/check-plugin.mjs | 260 +++++++++++++++++++++++++ scripts/fetch-beautifului-registry.mjs | 61 ++++++ 10 files changed, 558 insertions(+), 13 deletions(-) create mode 100644 docs/plugins/10-dependencies.md create mode 100644 docs/plugins/11-storage.md create mode 100644 scripts/check-plugin.mjs create mode 100644 scripts/fetch-beautifului-registry.mjs diff --git a/.gitignore b/.gitignore index f2a3309..1ef8c28 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,5 @@ release/ preview-shots/ *.tsbuildinfo capture-log.txt -scripts/ preview/ -"tmp-foundation.css" +"tmp-foundation.css" diff --git a/docs/plugins.md b/docs/plugins.md index 6a117f1..f4d4408 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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//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`) diff --git a/docs/plugins/08-performance.md b/docs/plugins/08-performance.md index 1197d5c..1970e9c 100644 --- a/docs/plugins/08-performance.md +++ b/docs/plugins/08-performance.md @@ -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 即把插件标记为失败 diff --git a/docs/plugins/09-troubleshooting.md b/docs/plugins/09-troubleshooting.md index adf647b..8ed2dd2 100644 --- a/docs/plugins/09-troubleshooting.md +++ b/docs/plugins/09-troubleshooting.md @@ -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=` 之后你自己的 `api.log` 输出 diff --git a/docs/plugins/10-dependencies.md b/docs/plugins/10-dependencies.md new file mode 100644 index 0000000..6662b50 --- /dev/null +++ b/docs/plugins/10-dependencies.md @@ -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 } + | 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` 却未声明依赖。 diff --git a/docs/plugins/11-storage.md b/docs/plugins/11-storage.md new file mode 100644 index 0000000..caa53c1 --- /dev/null +++ b/docs/plugins/11-storage.md @@ -0,0 +1,88 @@ +# L9 插件存储(SQLite) + +> 每个插件一块**私有**持久化空间:一个 SQLite 库 + 一个自由文件目录。 +> KV 简单状态、SQL 结构化数据、文件随便放;schema 必须机器可读声明——它是 Agent 自我迭代与 UI 展示的同一份事实源。 + +## 1. 空间布局 + +``` +userData/plugin-data// + 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. = 用户界面偏好' }, + 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('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// +}) +``` + +## 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)。 diff --git a/docs/plugins/README.md b/docs/plugins/README.md index 24d9f11..147604c 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -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)——症状 → 原因 → 修复三段式速查表。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 83da6b8..873dce3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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//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 子进程隔离模式 | --- diff --git a/scripts/check-plugin.mjs b/scripts/check-plugin.mjs new file mode 100644 index 0000000..a53a337 --- /dev/null +++ b/scripts/check-plugin.mjs @@ -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(' 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) diff --git a/scripts/fetch-beautifului-registry.mjs b/scripts/fetch-beautifului-registry.mjs new file mode 100644 index 0000000..55eed92 --- /dev/null +++ b/scripts/fetch-beautifului-registry.mjs @@ -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}`) + } +}