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. 行格式 + +``` + [] k=v k=v +2026-09-10T10:21:33.120Z INFO [plugin-host] plugin activated pluginId=demo-ui source=external +2026-09-10T10:21:33.451Z INFO [plugin] settings changed pluginId=demo-ui endpoint=staging +2026-09-10T10:21:40.002Z ERROR [ipc] ipc failed channel=plugins:setEnabled ms=1203 err=Error: runtime rebuild failed +``` + +scope 一览:`main` / `plugin-host` / `plugin`(所有插件日志,带 `pluginId` 字段)/ `agent-service` / `browser` / `ipc` / `renderer` / `logger`。 + +## 3. 插件怎么写日志:`api.log.*` + +```js +export default function definePlugin(api) { + const log = api.log // 自动带 pluginId 字段,进同一文件 + + api.log.info('activation payload', { tools: 3, mode: 'standard' }) + try { + risky() + } catch (err) { + api.log.error('risky step failed', { step: 'connect', err }) // err 会被序列化成 stack + } +} +``` + +**埋点规范**(Agent 排障依赖这些惯例): +- 激活时:`info` 一条「我装了什么」(能力清单/配置摘要) +- 关键决策与外部调用:`info` / `debug` + 结果字段 +- 一切 catch:`error` + `err` 字段——**禁止吞错不记** +- message 用英文短句、kv 用结构化字段(便于 grep `pluginId=xxx`) + +## 4. 宿主替你记录的(不用自己写) + +| 事件 | scope | 级别 | +|---|---|---| +| 插件扫描/装载/激活/禁用/重载全链路 | plugin-host | info | +| main.mjs 加载失败 / 激活失败 | plugin-host | error | +| 设置值变更 / 一级入口切换 / 按钮动作 | plugin-host | info/debug | +| 全部 73 个 IPC 通道调用(成功 debug 含耗时,失败 error) | ipc | debug/error | +| Agent 回合开始/结束/结算、运行时重建 | agent-service | info/error | +| 渲染层 console(含插件 renderer 模块与 iframe 页)、白屏、崩溃 | renderer | debug/error | +| 进程级 uncaughtException / unhandledRejection | logger | error | + +## 5. Agent 排障闭环(自迭代的核心流程) + +``` +发现问题(用户反馈 / 日志 error) + ↓ 读 userData/logs/main-<今天>.log + ↓ grep pluginId=<自己的id> 与 ERROR + ↓ 对照本文第 4 节判断:装载失败?激活抛错?IPC 超时?渲染崩溃? + ↓ 改插件代码(L0-L4 的对应章节) + ↓ 触发重载(设置页开关 / 重启)→ 再读日志验证新一次 plugin activated + ↓ 循环直到日志干净 +``` + +写插件时请遵守:**每个可能失败的操作都有日志、每条 error 都能定位到步骤**。这是 Agent 能替你维护插件的最低要求。 + +## 6. 快速自查清单 + +- [ ] 装载了吗:`external main loaded pluginId=` +- [ ] 激活了吗:`plugin activated pluginId=` +- [ ] 有报错吗:`ERROR` + `pluginId=` +- [ ] IPC 通吗:`ipc failed channel=…` 是宿主问题还是插件问题 +- [ ] 渲染崩了吗:`renderer console` 中 `[window.onerror]` / `[unhandledrejection]` + +--- + +**上一篇** [L4](./05-renderer-components.md) · 回到 [索引](./README.md) · 契约全集 [../plugins.md](../plugins.md) · 视觉规范 [DESIGN.md](./DESIGN.md) diff --git a/docs/plugins/DESIGN.md b/docs/plugins/DESIGN.md new file mode 100644 index 0000000..bdbc905 --- /dev/null +++ b/docs/plugins/DESIGN.md @@ -0,0 +1,129 @@ +# DESIGN.md · 插件视觉一致性规范 + +> 读者是**写 UI 的 Agent/开发者**。目标:任何来源的插件,界面观感与宿主完全一致。 +> 规则分两类:**强制**(可机器校验/一眼可辨)与**默认**(无充分理由不得偏离)。 +> 契约问题看 [../plugins.md](../plugins.md);本文件只管"长什么样"。 + +## 0. 两条铁律 + +1. **色值只允许来自 token**。任何 `#hex`/`rgb()` 硬编码(规范中列出的除外)都算违规;语义永远用 token 名表达(`text-ink-3` 而不是 `text-gray-400`)。 +2. **不发明新组件形态**。先查本文 §4 标准模式;确需新形态时,用 §1 的 token 组合拼装,不引入新色/新圆角/新阴影。 + +## 1. Token 表(单一事实源:`peer://assets/tokens.css` = 宿主 beautifului.css) + +### 表面(背景) + +| token | 意图 | +|---|---| +| `--page` | 页面最底层背景 | +| `--surface` | 卡片/面板表面 | +| `--inset` | 内嵌区域(输入框底、预览底、代码块底) | +| `--hover` / `--hover-2` | 悬停态一级/二级 | + +### 文字 + +| token | 意图 | +|---|---| +| `--ink` | 主文字(标题、关键值) | +| `--ink-2` | 次级文字(正文说明) | +| `--ink-3` | 弱化文字(占位、提示、元信息、路径) | + +### 边线 + +| token | 意图 | +|---|---| +| `--line` | 常规边框(卡片、列表分隔) | +| `--line-strong` | 强边框(输入框、可点击描边按钮) | +| `--line-soft` | 极弱分隔 | + +### 强调与语义 + +| token | 意图 | +|---|---| +| `--accent` / `--accent-ink` / `--accent-tint` | 品牌强调(主按钮、选中、链接) | +| `--green` / `--green-tint` | 成功/正常 | +| `--orange` / `--orange-tint` | 警示 | +| `--red` / `--red-tint` | 错误/危险 | + +### 阴影(全部是描边式,禁止投影类阴影) + +`--shadow-hairline`(发丝线)· `--shadow-btn`(按钮)· `--shadow-card`(卡片)· `--shadow-raised`(浮起)· `--shadow-overlay`(弹层)· `--shadow-inset-field`(内嵌输入) + +### 动效 + +`--duration-fast/base/slow`(统一 400ms)+ `--ease-standard`。悬停/切换用 `transition-colors` 即可。 + +## 2. 类名词汇(宿主 Tailwind 映射,renderer 组件直接用) + +背景 `bg-page / bg-surface / bg-inset / bg-hover / bg-accent / bg-accent/10` · +文字 `text-ink / text-ink-2 / text-ink-3 / text-accent / text-red` · +边框 `border-line / border-line-strong / border-dashed border-line` · +圆角 `rounded-card`(卡片/输入块)`rounded-control`(按钮/输入框/徽标)。 + +## 3. 字号与间距(默认) + +- 字号阶:`10px`(徽标)/ `11px`(辅助按钮)/ `11.5px`(次要按钮、说明)/ `12px`(正文说明) / `12.5px`(行标题/正文)/ `16px`(页面标题)。**不使用其他字号**;行高 `leading-4/5`。 +- 间距基数为 4px:卡片内边距 `px-3 py-2.5`,区块间 `space-y-1.5`,小节间 `pt-3`。 +- 图标 12–16px,线条风格,`currentColor`。 + +## 4. 标准模式(复制即用) + +### 设置行(label 左 / 控件右) + +```html +
+
+
标题
+
说明文字
+
+ +
+``` + +### 按钮 + +```html + + + + +``` + +### 徽标 + +```html +全局 v0.1.0 +``` + +### 空态 + +```html +
+
暂无数据
+
怎么产生的提示
+
+``` + +### 开关(switch) + +宿主规格:`h-5 w-9`(行内)/ `h-6 w-10`(表单级),滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。 + +## 5. iframe 插件页附加规则 + +- `` 必须引入 ``,并用同名 token 编译自己的 Tailwind(token 名 → 变量:`page/ink/ink-2/ink-3/line/line-strong/accent…`) +- 主题跟随宿主推送(`useHarnessTheme`),**禁止** `prefers-color-scheme` 自测 +- 与宿主的视觉差异应趋近于零:同 token + 同类名词汇 + 同字号阶 + +## 6. renderer 组件附加规则 + +- 类名直接用宿主词汇(§2),无需引样式 +- React 必须来自 `ctx.react`(见 [L4](./05-renderer-components.md)),否则 hooks 失效 +- 禁止操作宿主 DOM(document 全局查询/改样式),一切交互限制在自己的组件子树 + +## 7. 自检清单(提发布前) + +- [ ] 无硬编码色值/阴影/圆角(铁律 1) +- [ ] 字号都在 §3 的阶上 +- [ ] 深浅色都检查过(切主题看一眼;token 正确时自动成立) +- [ ] 复用了 §4 模式而非新造形态 +- [ ] iframe 页引了 tokens.css 且无 `prefers-color-scheme` diff --git a/docs/plugins/README.md b/docs/plugins/README.md new file mode 100644 index 0000000..fc3ac85 --- /dev/null +++ b/docs/plugins/README.md @@ -0,0 +1,34 @@ +# 插件开发教程(渐进式) + +> 面向「写插件的人(含 Agent)」。从零到高级共 6 篇,每篇 10-30 分钟,逐篇递进、可独立验证。 +> 契约细节(类型/字段全集)见 [../plugins.md](../plugins.md);UI 规范见 [DESIGN.md](./DESIGN.md)。 + +## 学习路径 + +| 篇 | 内容 | 产出 | +|---|---|---| +| [L0 第一个插件](./01-hello-plugin.md) | manifest + main.mjs,装载/启停/日志 | 侧栏可见、日志可查的最小插件 | +| [L1 工具与命令](./02-tools-and-commands.md) | 给 Agent 注册工具、斜杠命令、事件 | Agent 能力扩展 | +| [L2 设置表单](./03-settings.md) | 声明式 schema、13 种标准控件、一级入口 | 用户可配置的插件 | +| [L3 UI 槽位](./04-ui-slots.md) | 状态胶囊/工具栏按钮/视图/面板 tab/iframe 页 | 界面存在感 | +| [L4 React 组件](./05-renderer-components.md) | entry.renderer:宿主内 React 组件 + JSX 构建配方 | 一等公民级 UI | +| [L5 自诊断](./06-self-diagnosis.md) | 日志规范、排障闭环、Agent 自迭代约定 | 可被 Agent 维护的插件 | + +## 30 秒总览 + +``` +<插件目录>/ 三个可选位置(按优先级): + manifest.json 1. userData/plugins// (应用托管) + main.mjs (可选) 2. <工作区>/.pi/extensions// (项目级) + renderer.mjs (可选) 3. ~/.pi/agent/extensions// (全局) + index.html (可选) +``` + +- **main.mjs**:Agent 能力(工具/命令/事件)+ 声明设置表单 + 内联 UI 数据 +- **renderer.mjs**:宿主内 React 组件(需构建,见 L4) +- **index.html**:iframe 插件页(物理隔离,桥接见 L3) + +## 环境要求 + +- 第三方插件装载默认**仅开发版开启**(打包版关闭,`ASO_EXTERNAL_PLUGINS=1` 可豁免) +- 日志文件:`userData/logs/main-YYYY-MM-DD.log`(排查问题的第一入口,见 L5) diff --git a/docs/roadmap.md b/docs/roadmap.md index 49abb85..d906572 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,6 +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 | | 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 | --- diff --git a/packages/plugin-template/README.md b/packages/plugin-template/README.md new file mode 100644 index 0000000..a0ad951 --- /dev/null +++ b/packages/plugin-template/README.md @@ -0,0 +1,44 @@ +# plugin-template · 插件脚手架 + +复制本目录到插件位置(改目录名 = 改前先改 manifest.id),改掉 `id`/`name`/`description` 即完成最小定制。 + +## 目录 + +``` +manifest.json 清单(id/name/version/minContract 必改) +main.mjs 主进程侧:Agent 能力 + 设置声明 + 日志埋点(范例齐备) +src/renderer.tsx 宿主内 React 组件源码(JSX) +shims/ 构建期把 react 别名到宿主单例(勿改) +``` + +## 构建渲染层组件 + +```sh +esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \ + --jsx=automatic \ + --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js +``` + +(esbuild 可用 `pnpm dlx esbuild@<版本>` 或任意 ≥0.17 版本;`--alias` 需 ≥0.17。) +不需要 UI 时:删除 `entry.renderer` 与 `src/`、`shims/`,只保留 main.mjs。 + +## 放置位置(三选一,按优先级) + +1. `userData/plugins//`(应用托管目录) +2. `<工作区>/.pi/extensions//`(项目级) +3. `~/.pi/agent/extensions//`(全局) + +第三方装载仅开发版开启(`ASO_EXTERNAL_PLUGINS=1` 豁免)。 + +## 验证 + +1. 重启或开关重载 → 设置 → 插件 → 对应作用域出现插件 +2. 日志 `userData/logs/main-*.log` 有 `plugin activated pluginId=my-plugin` +3. 对 Agent 说"ping 一下我的插件" → 触发 `my_plugin_ping` +4. 面板/侧栏出现 React 组件,点击 +1 正常(hooks 生效) + +## 下一步 + +- 教程索引:`docs/plugins/README.md`(L0→L5) +- 视觉规范:`docs/plugins/DESIGN.md`(提发布前过一遍自检清单) +- 契约全集:`docs/plugins.md` diff --git a/packages/plugin-template/main.mjs b/packages/plugin-template/main.mjs new file mode 100644 index 0000000..d305f03 --- /dev/null +++ b/packages/plugin-template/main.mjs @@ -0,0 +1,43 @@ +// 主进程侧:Agent 能力(pi 工具/命令/事件)+ 设置声明 + 内联 UI。 +// 日志规范见 docs/plugins/06-self-diagnosis.md:激活时 info 一条能力清单,一切 catch 记 error。 +import { definePlugin } from '@aso/plugin-sdk' + +export default definePlugin((api) => { + const log = api.log + log.info('activated', { manifest: api.manifest.version }) + + // ---- 设置(用户可配置;值为宿主持久化) ---- + api.ui.registerSettings({ + items: [ + { type: 'string', key: 'greeting', label: '问候语', default: 'Hello', placeholder: 'Hello' }, + { type: 'boolean', key: 'verbose', label: '详细日志', default: false } + ] + }) + + let values = api.ui.getSettingValues() + api.ui.onSettingsChange((next) => { + values = next + log.info('settings changed', { greeting: next.greeting }) + }) + api.ui.onSettingsAction((key) => { + log.info('settings action', { key }) + }) + + // ---- Agent 工具示例 ---- + api.pi.registerTool({ + name: 'my_plugin_ping', + description: '返回问候语(示例工具:把这段换成真正的能力)', + parameters: {}, + async execute() { + log.debug('ping called', { verbose: values.verbose }) + return { output: `${values.greeting ?? 'Hello'} from my-plugin` } + } + }) + + // ---- 面板/视图组件在 renderer.mjs(JSX 构建见 README);无 UI 需求可整段去掉 ---- + log.info('ready', { hasRenderer: true }) + + return () => { + log.info('disposed') + } +}) diff --git a/packages/plugin-template/manifest.json b/packages/plugin-template/manifest.json new file mode 100644 index 0000000..5fbd322 --- /dev/null +++ b/packages/plugin-template/manifest.json @@ -0,0 +1,14 @@ +{ + "id": "my-plugin", + "name": "我的插件", + "version": "0.1.0", + "minContract": "0.2.0", + "description": "TODO:一句话描述插件做什么", + "order": 50, + "panel": true, + "icon": "", + "entry": { + "main": "main.mjs", + "renderer": "renderer.mjs" + } +} diff --git a/packages/plugin-template/shims/jsx-runtime.js b/packages/plugin-template/shims/jsx-runtime.js new file mode 100644 index 0000000..1062bf2 --- /dev/null +++ b/packages/plugin-template/shims/jsx-runtime.js @@ -0,0 +1,2 @@ +// 构建期别名目标:react/jsx-runtime → 宿主全局单例(esbuild --jsx=automatic) +export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime diff --git a/packages/plugin-template/shims/react.js b/packages/plugin-template/shims/react.js new file mode 100644 index 0000000..60b3051 --- /dev/null +++ b/packages/plugin-template/shims/react.js @@ -0,0 +1,16 @@ +// 构建期别名目标:react → 宿主全局单例(双 React 实例会导致 hooks 失效) +const R = window.__ASO_PLUGIN_HOST__.react +export default R +export const { + useState, + useEffect, + useRef, + useMemo, + useCallback, + createContext, + useContext, + Fragment, + memo, + forwardRef, + createElement +} = R diff --git a/packages/plugin-template/src/renderer.tsx b/packages/plugin-template/src/renderer.tsx new file mode 100644 index 0000000..e32d2d1 --- /dev/null +++ b/packages/plugin-template/src/renderer.tsx @@ -0,0 +1,29 @@ +/** + * 渲染层组件源码(JSX)。构建: + * esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \ + * --jsx=automatic --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js + * 产物 renderer.mjs 是自包含单文件,放回插件目录即被宿主装载。 + * 样式类名只用 DESIGN.md 里的宿主词汇。 + */ +import { useState } from 'react' + +export default function mount(ctx) { + function Panel() { + const [count, setCount] = useState(0) + return ( +
+
我的插件面板
+
计数:{count}
+ +
+ ) + } + + ctx.registerPanel(Panel) + ctx.registerView(Panel) +}