diff --git a/docs/plugins.md b/docs/plugins.md index 8345660..d62c35d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,7 +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/)。本文件是契约全集(参考手册)。 +> > 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap) -> 契约版本:`HARNESS_CONTRACT_VERSION = 0.1.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载) +> 契约版本:`HARNESS_CONTRACT_VERSION = 0.2.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载) ## 两层能力模型 diff --git a/docs/plugins/01-hello-plugin.md b/docs/plugins/01-hello-plugin.md new file mode 100644 index 0000000..611720a --- /dev/null +++ b/docs/plugins/01-hello-plugin.md @@ -0,0 +1,85 @@ +# L0 · 第一个插件 + +> 目标:10 分钟内拥有一个被宿主装载、可在日志里看到自己痕迹的最小插件。不需要构建工具。 + +## 1. 建目录 + +放在**全局**插件目录(所有工作区可见;项目级放 `<工作区>/.pi/extensions/`,见 README): + +``` +~/.pi/agent/extensions/hello/ + manifest.json + main.mjs +``` + +> Windows 下即 `C:\Users\<你>\.pi\agent\extensions\hello\`。 + +## 2. manifest.json + +```json +{ + "id": "hello", + "name": "Hello", + "version": "0.1.0", + "minContract": "0.1.0", + "description": "我的第一个插件" +} +``` + +字段规则:`id` 必须匹配 `^[a-z][a-z0-9-]*$`;`version` 是 semver;`minContract` 是要求的宿主契约版本(当前 `0.2.0`,填 `0.1.0` 即可向后兼容)。全字段见 [../plugins.md](../plugins.md#manifestjson)。 + +## 3. main.mjs + +```js +// 默认导出一个工厂:宿主激活插件时调用,返回的函数在禁用/重载时被调用 +export default function definePlugin(api) { + api.log.info('hello activated', { version: api.manifest.version }) + + // 演示:往 Agent 可见的目录里注册一个极简工具 + api.pi.registerTool({ + name: 'hello_now', + description: '返回当前时间的问候语', + parameters: {}, + async execute() { + api.log.debug('hello_now called') + return { output: `Hello! 现在是 ${new Date().toISOString()}` } + } + }) + + return () => { + api.log.info('hello disposed') + } +} +``` + +> `api` 的完整形状(pi / ui / storage / log / manifest)见 [../plugins.md](../plugins.md#主进程插件契约aso-plugin-sdk-主入口)。 + +## 4. 验证 + +1. 重启应用(`pnpm dev`;扫描发生在启动与插件重载时) +2. 打开 设置 → Agent 能力 → 插件 → **全局** 作用域,应看到「Hello v0.1.0」,开关为「已启用」 +3. 打开日志文件 `userData/logs/main-YYYY-MM-DD.log`,应能搜到: + +``` +<时间> INFO [plugin-host] external main loaded pluginId=hello version=0.1.0 +<时间> INFO [plugin-host] plugin activated pluginId=hello source=external +<时间> INFO [plugin] hello activated pluginId=hello version=0.1.0 +``` + +## 5. 改代码后如何生效 + +- 改 `main.mjs`:设置 → 插件页任意开关触发重载,或重启应用(宿主以 cache-bust 重新 import) +- 改 `manifest.json`:同上;`id` 变了等于新插件 + +## 常见装载失败 + +| 现象(日志里搜) | 原因 | +|---|---| +| `external main load failed` | main.mjs 语法错误 / import 了不存在的模块 | +| `external main invalid export` | 忘了 `export default`,或默认导出不是函数 | +| `需要契约版本 >=` | manifest.minContract 高于当前宿主契约(0.2.0) | +| 插件页全局作用域为空 | 目录放错(注意是 `extensions` 复数);或用户版构建(装载关闭) | + +--- + +**下一篇**:[L1 工具与命令](./02-tools-and-commands.md)——把真正的能力交给 Agent。 diff --git a/docs/plugins/02-tools-and-commands.md b/docs/plugins/02-tools-and-commands.md new file mode 100644 index 0000000..d66ee4f --- /dev/null +++ b/docs/plugins/02-tools-and-commands.md @@ -0,0 +1,91 @@ +# L1 · 工具与命令 + +> 前置:[L0](./01-hello-plugin.md)。`api.pi` 就是 pi 的原生 `ExtensionAPI`——pi CLI 扩展能做的这里都能做,语义一致。 + +## 1. 注册一个带参数的工具 + +工具是 Agent 的「手」:注册后自动进入系统提示词与工具列表,受审批拦截管理。 + +```js +export default function definePlugin(api) { + api.pi.registerTool({ + name: 'dice_roll', + description: '掷一个 N 面骰子,返回点数', + parameters: { + type: 'object', + properties: { + sides: { type: 'number', description: '面数,默认 6' } + } + }, + async execute(_id, params) { + const sides = Number(params.sides) || 6 + const value = 1 + Math.floor(Math.random() * sides) + api.log.info('dice rolled', { sides, value }) + return { output: String(value), metadata: { sides, value } } + } + }) +} +``` + +要点: +- `description` 会注入系统提示词,写清楚「什么时候该用这个工具」,Agent 的调用准确率取决于它 +- `execute` 返回 `{ output: string }`——output 是喂回模型的内容,保持精炼 +- 工具名全局唯一,用 `<插件名>_<动作>` 前缀避免撞名 + +## 2. 注册斜杠命令 + +命令是用户的「入口」:聊天框输入 `/` 出现,回车触发。 + +```js +export default function definePlugin(api) { + api.pi.registerCommand({ + name: 'dice', + description: '掷骰子', + handler: async (ctx, args) => { + const sides = Number(args) || 6 + ctx.notify(`🎲 ${1 + Math.floor(Math.random() * sides)} / ${sides}`) + } + }) +} +``` + +## 3. 订阅事件 + +在 Agent 生命周期钩子上挂逻辑(发消息前注入、工具调用前后处理等): + +```js +export default function definePlugin(api) { + const off = api.pi.on('before_agent_start', (event) => { + api.log.debug('agent starting', { prompt: event.prompt?.slice(0, 50) }) + }) + // 返回的取消函数放进 dispose,宿主在禁用/重载时调用 + return () => off() +} +``` + +常用事件:`before_agent_start` / `agent_start` / `tool_call` / `tool_result` / `before_provider_request` / `agent_end`。全集见 pi 的 `ExtensionAPI` 类型(`@earendil-works/pi-coding-agent`)。 + +## 4. 与插件私有存储配合 + +需要跨回合记住状态时用 `api.storage`(按插件隔离,落盘 `aso-plugin-storage.json`): + +```js +let count = api.storage.get('count') ?? 0 +api.storage.set('count', ++count) +``` + +## 5. 验证 + +1. 重载插件(开关切换或重启) +2. 日志确认 `plugin activated` +3. 聊天框输入 `/dice` 回车 → 收到点数;对 Agent 说「掷个 20 面骰」→ 观察它调用 `dice_roll` +4. 日志中应出现 `dice rolled sides=20 value=…` + +## 排障 + +- 工具没出现在 Agent 里:日志搜 `plugin activated` 确认激活成功;`registerTool` 抛错会记为 `plugin activation failed` +- 命令没出现在 `/` 面板:命令名冲突(重名被去重),换名再试 + +--- + +**上一篇** [L0](./01-hello-plugin.md) · **下一篇**:[L2 设置表单](./03-settings.md)——让用户能配置你的插件。 diff --git a/docs/plugins/03-settings.md b/docs/plugins/03-settings.md new file mode 100644 index 0000000..a65b27b --- /dev/null +++ b/docs/plugins/03-settings.md @@ -0,0 +1,90 @@ +# L2 · 设置表单 + +> 前置:[L0](./01-hello-plugin.md)。声明式 schema:插件声明「要什么配置」,宿主负责渲染、持久化、实时回推。不用写任何 UI 代码。 + +## 1. 声明设置 + +```js +export default function definePlugin(api) { + api.ui.registerSettings({ + items: [ + { type: 'string', key: 'apiKey', label: 'API Token', password: true, placeholder: 'sk-...' }, + { type: 'boolean', key: 'verbose', label: '详细日志', default: false }, + { + type: 'select', key: 'endpoint', label: '接入点', default: 'prod', + options: [ + { value: 'prod', label: '生产' }, + { value: 'staging', label: '预发' } + ] + }, + { type: 'button', key: 'test', label: '测试连接', variant: 'primary' } + ] + }) +} +``` + +保存后(开关切换或重启): +- 设置 → 插件 → **全局/项目** 作用域里,该插件行内出现 ⚙ 按钮(打开配置页)和「一级入口」开关 +- **items 为空或不声明 → 两个控件都不出现**(这正是"留空则无一级入口按钮"的规则) + +## 2. 一级入口 + +行内「一级入口」开关打开后,设置弹窗左侧导航「Agent 能力」组末尾出现以插件名命名的入口,点击进入该插件的独立配置页。用户随时可以关掉。 + +## 3. 读写值 + +```js +const values = api.ui.getSettingValues() // { apiKey: '...', verbose: false, endpoint: 'prod' } +// schema 里的 default 已合并——没被用户改过的键直接拿默认值 + +api.ui.onSettingsChange((next) => { // 用户在设置页改值后实时推送 + values = next + api.log.info('settings changed', { endpoint: next.endpoint }) +}) + +api.ui.onSettingsAction((key) => { // button 条目点击(key = 按钮项 key) + if (key === 'test') { + const ok = values.apiKey ? '✅' : '❌ 缺少 API Token' + api.ui.setStatusItem('test', { text: `连接测试 ${ok}`, tone: 'accent' }) + } +}) +``` + +值持久化在 `userData/aso-plugin-values.json`(按插件隔离),重装插件后仍在。 + +## 4. 13 种标准控件 + +| type | 说明 | 关键字段 | +|---|---|---| +| `string` | 单行输入 | `password` 掩码、`placeholder` | +| `text` | 多行文本 | `rows` | +| `number` | 数字 | `min` / `max` / `step` | +| `boolean` | 开关 | — | +| `select` | 下拉单选 | `options[{value,label}]` | +| `multiSelect` | 多选(chip 组) | `options` | +| `slider` | 滑条 | `min` / `max` 必填、`step` | +| `color` | 颜色(#rrggbb) | — | +| `path` | 文件/目录选择 | `mode: 'file' \| 'directory'` | +| `image` | 图片选择 + 缩略图 | — | +| `button` | 动作按钮 | `variant: 'primary' \| 'ghost'` | +| `info` | 静态说明 | — | +| `divider` | 分割线/小节标题 | `label?` | + +全部条目支持 `label` / `description` / `default`(button/info/divider 除外)。 + +## 5. 设计约束(为什么是数据不是代码) + +- schema 是**纯数据**:不允许函数/React 元素——宿主跨进程渲染并持久化快照 +- 需要动态选项(比如扫描出来的设备列表)?在激活时计算好再 `registerSettings`,每次激活都会重新声明 +- 需要复杂交互 UI?那不是设置的职责——走 [L4 React 组件](./05-renderer-components.md) + +## 6. 验证 + +1. 重载插件 → 插件页该行出现 ⚙ 与「一级入口」 +2. 打开配置页改一个值 → 日志出现 `settings changed`(`onSettingsChange` 生效) +3. 点按钮 → 状态胶囊出现(动作回调生效) +4. `userData/aso-plugin-values.json` 里能看到写入的键值 + +--- + +**上一篇** [L1](./02-tools-and-commands.md) · **下一篇**:[L3 UI 槽位](./04-ui-slots.md)——状态胶囊、面板、视图与 iframe 页。 diff --git a/docs/plugins/04-ui-slots.md b/docs/plugins/04-ui-slots.md new file mode 100644 index 0000000..07fac0d --- /dev/null +++ b/docs/plugins/04-ui-slots.md @@ -0,0 +1,88 @@ +# L3 · UI 槽位:胶囊、工具栏、视图、面板、iframe 页 + +> 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。 + +## 1. 状态胶囊(TitleBar 顶部) + +轻量状态提示,点击可深链跳转: + +```js +api.ui.setStatusItem('conn', { + text: '已连接 · 3 任务', + tone: 'accent', // default | accent | warn | danger + openView: { view: 'my-plugin' } // 点击跳到插件整页视图(可省略) +}) +api.ui.removeStatusItem('conn') +``` + +## 2. 工具栏按钮(输入区工具行) + +```js +api.ui.registerToolbarButton({ + id: 'open', + title: '打开我的面板', + icon: '', // 16-20px 线条风,currentColor + openView: { view: 'my-plugin' } +}) +``` + +## 3. 整页视图 + 侧栏入口 + +manifest 声明 `icon` / `order` 后,插件自动出现在侧栏(有 UI 能力时);任何槽位用 `openView` 深链: + +```js +api.ui.openView('my-plugin', { section: 'detail' }) // params 透传给插件 UI +api.ui.openView('chat') // 回到聊天 +``` + +## 4. 面板 tab(右侧 SidePanel) + +manifest 加 `"panel": true`,插件在右侧面板获得一个 tab。 + +## 5. iframe 插件页(entry.page) + +需要**完全自定义的 Web UI** 且不想构建 React 时用。物理隔离(sandbox iframe),经桥通信: + +``` +<插件目录>/index.html + 页面 JS +manifest: "entry": { "page": "index.html" } +``` + +页面侧用 SDK 桥(`@aso/plugin-sdk/client`): + +```html + +``` + +**样式统一(强制)**:插件页 `
` 里必须引宿主 token 表,并用自己的 Tailwind 按同样 token 名编译—— + +```html + +``` + +主题(深浅色/accent)由宿主推送(`useHarnessTheme`),禁止自行探测。详细规则见 [DESIGN.md](./DESIGN.md#iframe-插件页)。 + +## 6. 安全边界速记 + +| 形态 | 代码位置 | 能力 | +|---|---|---| +| main.mjs | 主进程 | 全权(与 pi 扩展同信任级) | +| renderer.mjs(L4) | 渲染层 | 宿主 React + window.api 受控面 | +| iframe 页 | 沙箱 iframe | 仅白名单桥(getState/send/getSettings/setStatus/openView) | + +## 7. 验证 + +1. 状态胶囊:`setStatusItem` 后 TitleBar 立即出现,`removeStatusItem` 消失 +2. 面板 tab:`panel: true` + 任一 UI 形态 → 右侧面板出现 tab +3. iframe 页:改页面文件 → 插件页开关触发重载 → iframe 换 src 整页刷新 +4. 全部 UI 状态在日志可查(`plugin activated` 之后你在 main.mjs 里的 `api.log` 输出) + +--- + +**上一篇** [L2](./03-settings.md) · **下一篇**:[L4 React 组件](./05-renderer-components.md)——宿主内真 React 组件。 diff --git a/docs/plugins/05-renderer-components.md b/docs/plugins/05-renderer-components.md new file mode 100644 index 0000000..8e8bb20 --- /dev/null +++ b/docs/plugins/05-renderer-components.md @@ -0,0 +1,91 @@ +# L4 · 宿主内 React 组件(entry.renderer) + +> 前置:[L3](./04-ui-slots.md)。iframe 页是隔离的「贴进去的网页」;`entry.renderer` 则是**真正的宿主内 React 组件**——与内置插件同槽位、同观感、同主题。这是外置插件的最高 UI 形态。 + +## 1. 声明 + +```json +{ "id": "my-plugin", "panel": true, "entry": { "renderer": "renderer.mjs" } } +``` + +> 文件名**不要叫 index.ts/index.js**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载。 + +## 2. renderer.mjs 契约 + +宿主动态 import 后调用默认导出 `mount(ctx)`: + +```js +export default function mount(ctx) { + // ctx.react 是宿主的 React 单实例——必须用它创建组件(自带 React 副本会让 hooks 全部失效) + const R = ctx.react + + function Panel() { + const [n, setN] = R.useState(0) + return R.createElement('div', { className: 'p-3 space-y-2' }, + R.createElement('div', { className: 'text-[12.5px] text-ink' }, `点击 ${n} 次`), + R.createElement('button', { + className: 'rounded-control border border-line-strong px-2.5 py-1 text-[11.5px] text-ink hover:bg-hover', + onClick: () => setN(n + 1) + }, '+1') + ) + } + + ctx.registerPanel(Panel) // 右侧面板 tab 内容(需 manifest.panel: true) + ctx.registerView(Panel) // 整页视图(侧栏入口 → 主内容区);两个都注册也可以 +} +``` + +## 3. JSX 工程构建配方(推荐) + +手写 `createElement` 只适合玩具;真实插件用 JSX + esbuild,构建时把 `react` 别名到**宿主全局实例**: + +```sh +esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \ + --jsx=automatic \ + --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js +``` + +```js +// shims/react.js —— 从宿主全局取单例 +const R = window.__ASO_PLUGIN_HOST__.react +export default R +export const { useState, useEffect, useRef, useMemo, useCallback, createContext, + useContext, Fragment, memo, forwardRef, createElement } = R +``` + +```js +// shims/jsx-runtime.js +export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime +``` + +`packages/plugin-template/` 已内置这套配置,复制即用。 + +## 4. 硬性约束 + +| 约束 | 原因 | +|---|---| +| React 必须来自 `ctx.react` / 宿主全局 | 双 React 实例 → hooks 全部报错 | +| 模块**自包含**(单文件 bundle) | 宿主以 Blob URL 装载,相对导入没有基路径 | +| 不 import 裸包名(react 等) | 同上;一切依赖打进 bundle(react 走别名除外) | +| 单文件 ≤ 8MB | 超限拒绝装载 | +| 仅开发版装载 | 用户版第三方装载关闭(`ASO_EXTERNAL_PLUGINS=1` 豁免) | + +## 5. 样式 + +组件直接跑在宿主树里——**宿主的 Tailwind 类与 token 全部可用**(`text-ink`、`border-line`、`rounded-card`、`bg-accent` …)。必须遵守 [DESIGN.md](./DESIGN.md) 的类名词汇与布局模式,禁止硬编码色值/阴影。 + +## 6. 生命周期 + +- 装载:插件列表变化(启停/重载/启动)即重装;`mount` 同步执行注册 +- 卸载:插件禁用/移除 → 注册的组件自动从宿主卸载 +- 崩溃隔离:渲染错误被 `PluginErrorBoundary` 捕获,不影响宿主;装载失败仅 console.error + 日志 + +## 7. 验证 + +1. 重载插件 → 插件页该行出现 ⚙(若已声明设置)/侧栏入口/面板 tab +2. 控制台无 `[plugins] … renderer 装载失败` +3. 点击组件内按钮,state 正常更新(hooks 生效 = 单实例约束满足) + +--- + +**上一篇** [L3](./04-ui-slots.md) · **下一篇**:[L5 自诊断](./06-self-diagnosis.md)——让 Agent 能发现并修复你的插件问题。 diff --git a/docs/plugins/06-self-diagnosis.md b/docs/plugins/06-self-diagnosis.md new file mode 100644 index 0000000..388e87f --- /dev/null +++ b/docs/plugins/06-self-diagnosis.md @@ -0,0 +1,84 @@ +# L5 · 自诊断:日志规范与 Agent 自迭代闭环 + +> 本项目的愿景是「Agent 写插件、Agent 排障、Agent 迭代」。达成的前提是**一切可观测**:宿主日志、插件日志、渲染层错误都落盘且结构化。这一篇讲日志从哪来、到哪去、Agent 怎么用它排障。 + +## 1. 日志在哪 + +``` +userData/logs/main-YYYY-MM-DD.log ← 唯一日志文件,按天滚动,保留 14 天 +``` + +Windows:`C:\Users\<你>\AppData\Roaming\@aso\desktop\logs\` +(Agent 侧:pi 的 read/grep 工具可直接读这个路径——排障第一步永远是读它。) + +级别:dev 默认 `debug`,打包版默认 `info`;环境变量 `ASO_LOG_LEVEL=debug|info|warn|error` 覆盖。 + +## 2. 行格式 + +``` +