Appearance
调试台插件开发指南
实验性功能
插件系统当前处于快速迭代阶段,API 可能会在后续版本中调整。本文档基于当前已实现的功能编写,如有疑问请参考仓库中的示例插件。
Playground 插件当前定位为内部受信扩展机制。插件代码直接在浏览器中运行,manifest.capabilities 用于声明插件会使用哪些宿主能力,便于宿主展示、人工审查和统一治理,不构成浏览器级安全沙箱。
最简插件结构
一个插件由 manifest.json 和入口文件组成:
my-plugin/
├── manifest.json
├── src/
│ └── index.ts
├── package.json
├── tsconfig.json
└── vite.config.ts构建产物:
dist/
├── index.js # 自包含的 ES 模块(所有依赖已内联)
└── manifest.jsonmanifest.json
json
{
"name": "com.example.my-plugin",
"version": "1.0.0",
"displayName": "我的插件",
"description": "一个示例插件",
"author": "Your Name",
"capabilities": ["network.fetch"],
"sdkVersion": "^1.0.0"
}完整字段说明
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 反域名格式 ID,全局唯一(如 com.example.my-plugin) |
version | 是 | 语义化版本号(如 1.0.0) |
displayName | 是 | 用户可见的名称 |
description | 是 | 功能描述。Agent 会看到这段文字来决定是否调用 |
author | 否 | 作者信息 |
capabilities | 是 | 声明插件会使用的宿主能力列表,见能力一览 |
sdkVersion | 否 | SDK 兼容版本范围(如 ^1.0.0),安装时会检查兼容性 |
icon | 否 | 插件图标(URL 或 base64) |
updateUrl | 否 | 插件更新检查地址 |
homepageUrl | 否 | 插件主页地址 |
changelog | 否 | 更新日志 |
configSchema | 否 | 配置界面声明,见插件配置 Schema |
tools | 否 | 静态工具元数据。通常由 src/index.ts 中的顶层 tools 自动派生,见静态工具元数据 |
静态工具元数据
推荐把工具定义集中写在 src/index.ts 的顶层 tools 中,作为单一定义源。SDK 会自动把这些完整工具定义派生为 manifest.tools,用于安装阶段的探测。
只有在“纯 manifest 探测、没有顶层 tools”的特殊场景下,才需要手写 manifest.tools:
json
{
"tools": [
{
"name": "my_tool",
"description": "这是一个示例工具",
"parameters": {
"type": "object",
"properties": {
"input": { "type": "string" }
},
"required": ["input"]
}
}
]
}设置后,宿主无需执行插件代码即可获知工具名列表。更常见的情况是:你在顶层 tools 中声明完整工具,SDK 自动同步出 manifest.tools,这样探测、展示和真实执行始终保持一致。
插件配置 Schema
configSchema 允许你声明结构化的配置界面:
json
{
"configSchema": {
"description": "插件配置说明",
"sections": [
{
"title": "API 设置",
"description": "配置外部 API 连接",
"fields": [
{
"key": "apiKey",
"label": "API Key",
"type": "password",
"required": true,
"description": "你的服务 API 密钥"
},
{
"key": "baseUrl",
"label": "服务地址",
"type": "string",
"defaultValue": "https://api.example.com",
"placeholder": "输入 API 地址"
},
{
"key": "timeout",
"label": "超时时间(秒)",
"type": "number",
"defaultValue": 30,
"min": 5,
"max": 120
},
{
"key": "format",
"label": "输出格式",
"type": "select",
"options": [
{ "label": "JSON", "value": "json" },
{ "label": "CSV", "value": "csv" },
{ "label": "Markdown", "value": "markdown" }
],
"defaultValue": "json"
},
{
"key": "verbose",
"label": "详细输出",
"type": "boolean",
"defaultValue": false
}
]
}
]
}
}字段类型:string | number | boolean | select | password
验证规则:
required— 是否必填min/max— 数值范围(number 类型)pattern— 正则验证(string 类型)patternMessage— 正则验证失败的提示信息
插件代码中通过 ctx.getConfig() 读取当前配置值,通过 ctx.onConfigChange(handler) 监听配置变化。
src/index.ts
typescript
import { definePlugin, defineTool } from '@playground/plugin-sdk'
const myTool = defineTool({
name: 'my_tool',
description: '这是一个示例工具',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: '输入内容' },
},
required: ['input'],
},
execute: async (args, api) => {
api.reportProgress({ phase: 'working', message: '正在处理...' })
return { success: true, result: `你输入了: ${args.input}` }
},
})
export default definePlugin({
manifest: {
name: 'com.example.my-plugin',
version: '1.0.0',
displayName: '我的插件',
description: '一个示例插件',
capabilities: ['network.fetch'],
},
tools: [myTool],
})PluginContext API
install(ctx) 中的 ctx 对象提供以下方法。注意:它现在主要服务于“额外初始化逻辑”,不是推荐的工具主注册入口:
| 方法 | 说明 |
|---|---|
ctx.registerTool(tool) | 兼容旧写法保留。新插件应优先使用顶层 tools 声明工具;这个方法主要留给旧插件或动态注册场景 |
ctx.registerWidgetKind(kind, component) | 注册一个自定义 Widget 类型和对应的 Vue 组件 |
ctx.getConfig() | 获取当前插件配置(用户在设置面板中填写的值) |
ctx.onConfigChange(handler) | 监听配置变化,返回取消监听函数 |
推荐约定:
tools:声明所有工具,作为工具 schema 和执行实现的单一来源install(ctx):只处理 Widget 注册、事件订阅或其他额外初始化逻辑
vite.config.ts
typescript
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: () => 'index.js',
},
rollupOptions: {
output: {
assetFileNames: 'assets/[name].[ext]',
},
},
outDir: 'dist',
emptyOutDir: true,
minify: false,
},
})package.json
json
{
"name": "@playground-plugin/my-plugin",
"private": true,
"type": "module",
"scripts": {
"build": "vite build && cp manifest.json dist/manifest.json",
"pack": "npm run build && cd dist && zip -r ../my-plugin.zip ."
},
"dependencies": {
"@playground/plugin-sdk": "file:../../playground-plugin-sdk"
},
"devDependencies": {
"vite": "^6.0.0",
"typescript": "^5.0.0"
}
}构建与安装
bash
# 安装依赖
npm install
# 构建
npm run build
# 打包为 ZIP
npm run pack
# 在调试台的插件管理面板中上传 my-plugin.zip插件 API 参考
插件工具的 execute 函数接收 (args, api) 参数。args 是工具调用参数,api 是 PluginRuntimeAPI 实例。
api 方法完整列表
网络请求
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.fetch(url, init?) | 发送 HTTP 请求,返回标准 Response | network.fetch |
typescript
const resp = await api.fetch(`https://api.example.com/data?q=${encodeURIComponent(args.query)}`)
if (!resp.ok) {
return { success: false, error: `请求失败: HTTP ${resp.status}` }
}
const data = await resp.json()
return { success: true, data }用户交互
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.askUser(options) | 向用户提问并获取回答 | user_interact |
typescript
const answer = await api.askUser({
title: '确认操作',
question: '请选择处理方式',
type: 'single', // 'single' | 'multi' | 'text'
options: [
{ label: '方式一', value: 'option1' },
{ label: '方式二', value: 'option2' },
],
allowOther: true, // 允许用户输入自定义答案
placeholder: '请选择...',
required: true,
})
// answer = { values: ['option1'], text: '' }Widget 展示
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.showWidget(widget) | 返回 Widget 展示数据 | ui.widget |
typescript
return api.showWidget({
title: '处理结果',
kind: 'my-custom-widget',
summary: '简要描述',
data: { /* Widget 数据 */ },
controls: [ /* 可交互控件 */ ],
actions: [ /* 操作按钮 */ ],
})通知
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.showNotification(options) | 显示通知 | ui.notification |
typescript
api.showNotification({
type: 'success', // 'info' | 'success' | 'warning' | 'error'
title: '处理完成',
description: '文件已成功转换',
})进度报告
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.reportProgress({ phase, message, progress? }) | 报告工具执行进度 | 无需额外能力声明 |
文件操作
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.workspace.readFile(path) | 读取文件为文本 | workspace.user 或 workspace.plugin |
api.workspace.writeFile(path, content) | 写入文件(支持 string 或 Uint8Array) | workspace.user 或 workspace.plugin |
api.workspace.deleteFile(path) | 删除文件 | workspace.user 或 workspace.plugin |
api.workspace.listFiles(path?) | 列出目录内容 | workspace.user 或 workspace.plugin |
api.workspace.readFileBlob(path) | 读取文件为 Blob | workspace.user 或 workspace.plugin |
插件资源
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.readPluginAsset(path) | 读取插件 src/ 目录下的静态资源(如 WASM、数据文件) | 无需额外能力声明 |
typescript
const wasmBlob = await api.readPluginAsset('assets/ffmpeg-core.wasm')
const wasmUrl = URL.createObjectURL(wasmBlob)Python 运行时
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.runPython(options) | 在浏览器端执行 Python 代码 | workspace.python |
typescript
const result = await api.runPython({
code: 'print("Hello from plugin")',
files: ['data/input.csv'], // 可选:关联工作区文件
timeoutMs: 30000, // 可选:超时时间
})
// result = { success: true, stdout: 'Hello from plugin\n', stderr: '', result: null }WASM 运行时
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.runWasm(options) | 加载并实例化 WASM 模块 | workspace.wasm |
api.wasmCall(instanceId, fnName, args?) | 调用 WASM 实例的导出函数 | workspace.wasm |
api.wasmWriteMemory(instanceId, offset, data) | 向 WASM 内存写入数据 | workspace.wasm |
api.wasmReadMemory(instanceId, offset, length) | 从 WASM 内存读取数据 | workspace.wasm |
api.wasmReadLog(instanceId) | 读取 WASM 实例的日志 | workspace.wasm |
api.wasmDestroy(instanceId) | 销毁 WASM 实例 | workspace.wasm |
typescript
// 加载 WASM 模块
const instance = await api.runWasm({
module: 'assets/my-module.wasm', // 也可以是 ArrayBuffer 或 URL
imports: { /* WASM 导入配置 */ },
timeoutMs: 30000,
})
// instance = { instanceId: '...', exports: ['add', 'multiply'], memoryPages: 1 }
// 调用函数
const result = await api.wasmCall(instance.instanceId, 'add', [1, 2])
// 读写内存
await api.wasmWriteMemory(instance.instanceId, 0, new Uint8Array([1, 2, 3]))
const data = await api.wasmReadMemory(instance.instanceId, 0, 3)
// 用完后销毁
await api.wasmDestroy(instance.instanceId)runWasm 的 module 参数支持三种形式:
string:相对于插件assets/目录的路径(如'assets/ffmpeg-core.wasm')ArrayBuffer:直接的 WASM 二进制数据URL:WASM 文件的 URL
imports 参数支持多种格式,用于配置 WASM 模块的导入对象,包括内存分配、表、错误处理等。
持久化存储
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.storage.get(key) | 获取值 | storage |
api.storage.set(key, value) | 设置值 | storage |
api.storage.delete(key) | 删除键 | storage |
api.storage.list() | 列出所有键 | storage |
存储是插件作用域隔离的,不同插件的存储互不影响。
会话信息
| API 方法 | 说明 | 需要能力声明 |
|---|---|---|
api.getSessionInfo() | 获取当前会话信息 | 无需额外能力声明 |
api.getConfig() | 获取当前插件配置 | 无需额外能力声明 |
typescript
const info = api.getSessionInfo()
// info = { id: 'session-xxx', folderName: 'agent-xxx', mode: 'agent' | 'chat' }
const config = api.getConfig()
// config = { apiKey: '...', baseUrl: '...' } // 用户在设置面板中配置的值返回结果约定
推荐使用 { success: boolean } 格式:
typescript
// 成功
return { success: true, city: '北京', temperature: 25 }
// 失败
return { success: false, error: '城市名称无法识别' }能力一览
| 能力 | 说明 | 典型用途 |
|---|---|---|
network.fetch | HTTP 请求 | 调用外部 API |
workspace.user | 读写用户工作区文件 | 处理用户上传的文件 |
workspace.plugin | 读写插件沙箱目录 | 插件内部文件管理 |
workspace.python | 执行 Python 代码 | 数据处理 |
workspace.wasm | 运行 WASM 模块 | 高性能计算 |
user_interact | 向用户提问 | 获取用户输入 |
ui.widget | 显示自定义组件 | 可视化展示 |
ui.notification | 显示通知 | 状态提醒 |
storage | 插件 KV 存储 | 持久化配置 |
进阶:构建含 WASM / Worker 的插件
当插件需要处理 WASM 二进制、Web Worker 等复杂依赖时,需要注意以下几点:
插件加载机制
- 安装:ZIP 解压到浏览器 OPFS 中的插件目录
- 加载:读取 JS 文件 → 创建 Blob URL → 重写相对 import 路径 →
import(blobUrl)动态加载 - 执行:运行顶层
tools,并调用plugin.install(ctx)做额外初始化(如注册 Widget)
关键限制:插件代码以 Blob URL 形式在浏览器中执行,import.meta.url 是 blob:https://...,无法解析常规的相对路径。因此:
- 所有 npm 依赖必须在构建时由 Vite 打包进
index.js,不要将包设为external - 裸模块说明符(如
@ffmpeg/ffmpeg)无法在运行时被浏览器解析
WASM 作为静态资源
WASM 文件不适合内联到 JS 中(如 ffmpeg-core.wasm 有 31MB)。正确做法是:
- 将 WASM 包声明为依赖
- 构建后用脚本拷贝 WASM 到
dist/assets/ - 运行时通过
api.readPluginAsset()或api.runWasm({ module: 'assets/...' })读取
typescript
// 方式一:使用 api.runWasm 直接加载
const instance = await api.runWasm({
module: 'assets/ffmpeg-core.wasm',
})
// 方式二:手动读取并加载
const wasmBlob = await api.readPluginAsset('assets/ffmpeg-core.wasm')
const wasmBytes = await wasmBlob.arrayBuffer()
const instance = await api.runWasm({ module: wasmBytes })Web Worker 处理
如果库内部创建 Web Worker,Worker 的 JS 源码如果有外部 import(ESM 格式),在 Blob URL 环境下无法解析。
解决方案:使用 Vite 打包后的自包含 Worker chunk(IIFE 格式)。Vite 会自动处理 new Worker(new URL(...)) 模式,生成自包含的 Worker chunk。构建脚本需要将其重命名为稳定文件名:
javascript
// 构建后脚本:重命名 Vite 生成的 worker chunk
import { readdirSync, renameSync } from 'node:fs'
const distAssets = 'dist/assets'
const workerFile = readdirSync(distAssets).find(f => /^worker-.*\.js$/.test(f))
if (workerFile) {
renameSync(`${distAssets}/${workerFile}`, `${distAssets}/worker.js`)
}运行时加载 Worker:
typescript
const workerBlob = await api.readPluginAsset('assets/worker.js')
const workerBlobUrl = URL.createObjectURL(
new Blob([await workerBlob.text()], { type: 'text/javascript' })
)workspace 能力选择
| 能力 | 文件访问范围 | 适用场景 |
|---|---|---|
workspace.plugin | plugins/{name}/ 沙箱目录 | 插件内部文件管理 |
workspace.user | 用户工作区根目录 | 处理用户的文件(如音视频转换) |
Agent 传入的路径(如 files/video.mp4)是相对于用户工作区根目录的。如果需要处理用户工作区中的文件,应声明 workspace.user 能力。
简单插件 vs 复杂插件
| 简单插件 | 复杂插件(含 WASM) | |
|---|---|---|
| 依赖 | 全部打包进 index.js | WASM 作为静态资源放在 assets/ |
| 能力 | network.fetch | workspace.user(读写用户文件) |
| Worker | 无 | 需用 Vite 打包的 IIFE 格式 |
| 构建脚本 | 仅 vite build | 额外拷贝 WASM + 重命名 Worker |
| ZIP 大小 | ~5KB | ~10MB |