docs: 渐进式插件教程(L0→L5)+ DESIGN.md 视觉规范 + plugin-template 脚手架

This commit is contained in:
Rick Ye
2026-09-11 02:18:01 +08:00
parent 8e4b5496e8
commit 4ed31a34fc
16 changed files with 844 additions and 1 deletions
+3 -1
View File
@@ -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 与之比对,不满足拒载)
## 两层能力模型
+85
View File
@@ -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。
+91
View File
@@ -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)——让用户能配置你的插件。
+90
View File
@@ -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 页。
+88
View File
@@ -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: '<svg .../>', // 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
<script type="module">
import { getHarnessClient, useHarnessTheme, useDeepLinkParams } from './sdk-client.mjs'
const harness = getHarnessClient('my-plugin')
harness.openView('chat') // 深链
harness.setStatus('state', { text: '就绪' })
harness.invoke('getState') // 白名单:getState / send / getSettings
</script>
```
**样式统一(强制)**:插件页 `<head>` 里必须引宿主 token 表,并用自己的 Tailwind 按同样 token 名编译——
```html
<link rel="stylesheet" href="peer://assets/tokens.css" />
```
主题(深浅色/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 组件。
+91
View File
@@ -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 能发现并修复你的插件问题。
+84
View File
@@ -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. 行格式
```
<ISO时间> <LEVEL> [<scope>] <message> 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=<id>`
- [ ] 激活了吗:`plugin activated pluginId=<id>`
- [ ] 有报错吗:`ERROR` + `pluginId=<id>`
- [ ] IPC 通吗:`ipc failed channel=…` 是宿主问题还是插件问题
- [ ] 渲染崩了吗:`renderer console` 中 `[window.onerror]` / `[unhandledrejection]`
---
**上一篇** [L4](./05-renderer-components.md) · 回到 [索引](./README.md) · 契约全集 [../plugins.md](../plugins.md) · 视觉规范 [DESIGN.md](./DESIGN.md)
+129
View File
@@ -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
<div class="flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5">
<div class="min-w-0">
<div class="text-[12.5px] text-ink">标题</div>
<div class="mt-0.5 text-[11px] leading-4 text-ink-3">说明文字</div>
</div>
<!-- 控件 -->
</div>
```
### 按钮
```html
<!-- primary -->
<button class="rounded-control bg-accent px-3 py-1.5 text-[11.5px] font-medium text-white hover:opacity-90">主操作</button>
<!-- ghost -->
<button class="rounded-control border border-line-strong px-3 py-1.5 text-[11.5px] text-ink hover:bg-hover">次要操作</button>
```
### 徽标
```html
<span class="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">全局 v0.1.0</span>
```
### 空态
```html
<div class="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
<div class="text-[13px] font-medium text-ink-2">暂无数据</div>
<div class="text-[11.5px] text-ink-3">怎么产生的提示</div>
</div>
```
### 开关(switch)
宿主规格:`h-5 w-9`(行内)/ `h-6 w-10`(表单级),滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。
## 5. iframe 插件页附加规则
- `<head>` 必须引入 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 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`
+34
View File
@@ -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/<id>/ (应用托管)
main.mjs (可选) 2. <工作区>/.pi/extensions/<id>/ (项目级)
renderer.mjs (可选) 3. ~/.pi/agent/extensions/<id>/ (全局)
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)
+1
View File
@@ -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 子进程隔离模式 |
---
+44
View File
@@ -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/<id>/`(应用托管目录)
2. `<工作区>/.pi/extensions/<id>/`(项目级)
3. `~/.pi/agent/extensions/<id>/`(全局)
第三方装载仅开发版开启(`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`
+43
View File
@@ -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')
}
})
+14
View File
@@ -0,0 +1,14 @@
{
"id": "my-plugin",
"name": "我的插件",
"version": "0.1.0",
"minContract": "0.2.0",
"description": "TODO:一句话描述插件做什么",
"order": 50,
"panel": true,
"icon": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/></svg>",
"entry": {
"main": "main.mjs",
"renderer": "renderer.mjs"
}
}
@@ -0,0 +1,2 @@
// 构建期别名目标:react/jsx-runtime → 宿主全局单例(esbuild --jsx=automatic)
export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime
+16
View File
@@ -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
+29
View File
@@ -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 (
<div className="flex h-full flex-col gap-2 p-3">
<div className="text-[12.5px] text-ink">我的插件面板</div>
<div className="text-[12px] text-ink-3">计数:{count}</div>
<button
className="self-start rounded-control border border-line-strong px-2.5 py-1 text-[11.5px] text-ink hover:bg-hover"
onClick={() => setCount((c) => c + 1)}
>
+1
</button>
</div>
)
}
ctx.registerPanel(Panel)
ctx.registerView(Panel)
}