第 24 章
编写第一个 Plugin:Channel / Tool / Provider 三种类型实战
第24章 编写第一个 Plugin:Channel / Tool / Provider 三种类型实战
24.1 Plugin 目录结构与 Manifest 格式规范
在开始编写 Plugin 代码之前,先建立正确的目录结构。无论是 Channel、Tool 还是 Provider Plugin,目录结构遵循同一规范。
标准 Plugin 目录结构
my-plugin/
├── plugin.manifest.yaml # 控制面配置(必须)
├── package.json # npm 包描述
├── tsconfig.json # TypeScript 配置
├── src/
│ └── index.ts # Plugin 入口(开发用)
├── dist/
│ └── index.js # 编译输出(生产用)
└── README.md # Plugin 文档
package.json 基础配置
{
"name": "@myorg/openclaw-plugin-example",
"version": "1.0.0",
"description": "Example OpenClaw Plugin",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"prepublish": "npm run build"
},
"peerDependencies": {
"@openclaw/plugin-api": "^2.0.0"
},
"devDependencies": {
"@openclaw/plugin-api": "^2.0.0",
"typescript": "^5.0.0"
}
}
tsconfig.json 推荐配置
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Plugin Manifest 完整格式
# plugin.manifest.yaml 完整字段说明
id: my-example-plugin # 必填:全局唯一 ID(小写字母、数字、连字符)
version: "1.0.0" # 必填:SemVer 版本号
displayName: Example Plugin # 必填:用户可见的名称
description: > # 必填:功能描述
An example plugin demonstrating all manifest fields.
author: MyOrg # 推荐:发布者名称或组织
homepage: https://github.com/myorg/my-example-plugin
# 入口点
entryPoint: dist/index.js # 必填:生产模式入口(已构建 JS)
devEntryPoint: src/index.ts # 推荐:开发模式入口(TypeScript 源码)
# API 兼容性
apiVersion: "2.0" # Plugin API 版本要求
# 能力声明(影响 Registry 行为)
capabilities:
- channel # 注册了消息通道
- tool # 注册了工具
- provider # 注册了 LLM Provider
- command # 注册了 CLI 命令
- httpRoute # 注册了 HTTP 端点
# 权限声明(Safety Gates 会检查)
permissions:
network:
- "*.slack.com"
- "slack.com"
fileSystem:
- read: "~/.config/my-plugin/**"
# 配置 schema(JSON Schema 格式)
configuration:
schema:
type: object
properties:
apiKey:
type: string
secret: true # secret: true 触发加密存储
description: API key for authentication
enabled:
type: boolean
default: true
required: [apiKey]
24.2 Channel Plugin 实战:新增一个消息平台
需求描述
我们要构建一个 Discord Channel Plugin,让 OpenClaw Agent 能够接收并响应 Discord 消息。
完整实现
// src/index.ts
import type { PluginApi, ChannelMessage, ChannelContext } from '@openclaw/plugin-api'
import { Client, GatewayIntentBits, Message } from 'discord.js'
export async function setup(api: PluginApi) {
const config = api.getConfig<{
botToken: string
guildId: string
channelAllowlist: string[]
commandPrefix: string
}>()
// 初始化 Discord 客户端
const discordClient = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
})
// 注册 Discord 消息通道
api.registerChannel({
id: 'discord',
displayName: 'Discord',
// 通道初始化:连接到 Discord 并开始监听
onInit: async (channelHandler) => {
discordClient.on('messageCreate', async (message: Message) => {
// 过滤:忽略机器人消息
if (message.author.bot) return
// 过滤:只处理允许列表中的频道
if (config.channelAllowlist.length > 0 &&
!config.channelAllowlist.includes(message.channelId)) {
return
}
// 过滤:检查命令前缀(如 "!ai ")
if (config.commandPrefix && !message.content.startsWith(config.commandPrefix)) {
return
}
const content = config.commandPrefix
? message.content.slice(config.commandPrefix.length).trim()
: message.content
// 构建标准 ChannelMessage 并提交给 Agent 处理
const channelMessage: ChannelMessage = {
id: message.id,
content,
author: {
id: message.author.id,
displayName: message.author.displayName,
},
threadId: message.channelId,
timestamp: message.createdAt,
attachments: message.attachments.map(att => ({
type: att.contentType?.startsWith('image/') ? 'image' : 'file',
url: att.url,
name: att.name ?? 'attachment',
})),
}
// 触发 Agent 推理
await channelHandler.onMessage(channelMessage)
})
await discordClient.login(config.botToken)
console.log(`[discord-plugin] Logged in as ${discordClient.user?.tag}`)
},
// 发送响应到 Discord
sendMessage: async (response, originalMessage) => {
const channel = await discordClient.channels.fetch(originalMessage.threadId)
if (!channel?.isTextBased()) {
throw new Error(`Channel ${originalMessage.threadId} is not text-based`)
}
// 处理长消息(Discord 限制 2000 字符)
const chunks = splitIntoChunks(response.content, 1900)
for (const chunk of chunks) {
await channel.send(chunk)
}
},
// 支持输入状态提示(用户看到"正在输入...")
sendTypingIndicator: async (threadId) => {
const channel = await discordClient.channels.fetch(threadId)
if (channel?.isTextBased()) {
await channel.sendTyping()
}
},
// 通道关闭时清理
onDestroy: async () => {
discordClient.destroy()
console.log('[discord-plugin] Discord client disconnected')
},
})
}
// 辅助函数:将长文本切分为多个块
function splitIntoChunks(text: string, maxLength: number): string[] {
const chunks: string[] = []
let current = ''
for (const line of text.split('\n')) {
if (current.length + line.length + 1 > maxLength) {
if (current) chunks.push(current.trim())
current = line
} else {
current += (current ? '\n' : '') + line
}
}
if (current) chunks.push(current.trim())
return chunks
}
对应的 Manifest
# plugin.manifest.yaml(Discord Channel Plugin)
id: discord-channel-plugin
version: "1.0.0"
displayName: Discord Channel
description: Enables OpenClaw Agent to receive and respond to Discord messages.
author: MyOrg
entryPoint: dist/index.js
devEntryPoint: src/index.ts
apiVersion: "2.0"
capabilities:
- channel
permissions:
network:
- "*.discord.com"
- "discord.com"
- "gateway.discord.gg"
configuration:
schema:
type: object
properties:
botToken:
type: string
secret: true
description: Discord bot token from Discord Developer Portal
guildId:
type: string
description: Discord server (guild) ID to operate in
channelAllowlist:
type: array
items:
type: string
default: []
description: Only respond in these channel IDs (empty = all channels)
commandPrefix:
type: string
default: "!ai "
description: Message prefix that triggers the Agent (empty = all messages)
required: [botToken, guildId]
关键接口总结
| 接口 | 必须实现 | 说明 |
|---|---|---|
onInit(channelHandler) |
是 | 连接平台,开始监听消息 |
sendMessage(response, original) |
是 | 将 Agent 响应发回平台 |
sendTypingIndicator(threadId) |
否 | 发送"正在输入"指示器 |
onDestroy() |
推荐 | 清理连接,防止资源泄漏 |
24.3 Tool Plugin 实战:暴露一个新工具
需求描述
构建一个 "Database Query Tool",让 Agent 能够对 PostgreSQL 数据库执行只读查询。
完整实现
// src/index.ts
import type { PluginApi } from '@openclaw/plugin-api'
import { Pool } from 'pg'
import { z } from 'zod'
export async function setup(api: PluginApi) {
const config = api.getConfig<{
connectionString: string
maxConnections: number
queryTimeoutMs: number
allowedSchemas: string[]
}>()
// 初始化连接池
const pool = new Pool({
connectionString: config.connectionString,
max: config.maxConnections ?? 5,
idleTimeoutMillis: 30000,
})
// 测试连接
const client = await pool.connect()
client.release()
console.log('[db-query-plugin] Connected to PostgreSQL')
// 注册数据库查询工具
api.registerTool({
id: 'database.query',
displayName: 'Database Query',
description: `Execute a read-only SQL query against the database.
Only SELECT statements are allowed.
Available schemas: ${config.allowedSchemas.join(', ')}.`,
// 工具输入 schema(JSON Schema 格式)
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The SQL SELECT query to execute',
},
limit: {
type: 'integer',
description: 'Maximum number of rows to return (default: 100, max: 1000)',
default: 100,
minimum: 1,
maximum: 1000,
},
parameters: {
type: 'array',
items: {},
description: 'Query parameters for parameterized queries ($1, $2, ...)',
default: [],
},
},
required: ['query'],
},
// 工具执行 handler
handler: async (input, ctx) => {
// 运行时 Zod 验证(提供更好的错误信息)
const params = z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(1000).default(100),
parameters: z.array(z.unknown()).default([]),
}).parse(input)
// 安全检查1:只允许 SELECT 语句
const normalizedQuery = params.query.trim().toLowerCase()
if (!normalizedQuery.startsWith('select')) {
throw new ToolError('Only SELECT queries are allowed for safety reasons.')
}
// 安全检查2:不允许子查询写操作
const dangerousKeywords = ['insert', 'update', 'delete', 'drop', 'truncate', 'create', 'alter']
for (const keyword of dangerousKeywords) {
if (normalizedQuery.includes(keyword)) {
throw new ToolError(`Query contains forbidden keyword: ${keyword}`)
}
}
// 安全检查3:只允许访问指定 schema
for (const schema of config.allowedSchemas) {
// 检查查询只访问允许的 schema(简化实现)
}
// 添加 LIMIT 保护
const finalQuery = params.query.includes('LIMIT')
? params.query
: `${params.query} LIMIT ${params.limit}`
// 执行查询(带超时)
const client = await pool.connect()
try {
await client.query(`SET statement_timeout = ${config.queryTimeoutMs ?? 5000}`)
const result = await client.query(finalQuery, params.parameters)
return {
rowCount: result.rowCount,
columns: result.fields.map(f => ({
name: f.name,
type: getPostgresTypeName(f.dataTypeID),
})),
rows: result.rows,
// 提供人类可读的摘要
summary: `Query returned ${result.rowCount} row(s) with columns: ${
result.fields.map(f => f.name).join(', ')
}`,
}
} finally {
client.release()
}
},
})
// 注册第二个工具:列出数据库表
api.registerTool({
id: 'database.listTables',
displayName: 'List Database Tables',
description: 'List all available tables in the allowed schemas.',
inputSchema: {
type: 'object',
properties: {
schema: {
type: 'string',
description: 'Schema to list tables from (default: public)',
default: 'public',
},
},
},
handler: async (input) => {
const schema = (input.schema as string) ?? 'public'
if (!config.allowedSchemas.includes(schema)) {
throw new ToolError(`Schema '${schema}' is not in the allowed list.`)
}
const client = await pool.connect()
try {
const result = await client.query(
`SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = $1
ORDER BY table_name`,
[schema]
)
return {
schema,
tables: result.rows,
count: result.rowCount,
}
} finally {
client.release()
}
},
})
// 清理:进程退出时关闭连接池
process.on('exit', () => pool.end())
}
function getPostgresTypeName(oid: number): string {
const types: Record<number, string> = {
16: 'boolean', 20: 'bigint', 21: 'smallint', 23: 'integer',
25: 'text', 700: 'float4', 701: 'float8', 1082: 'date',
1114: 'timestamp', 1184: 'timestamptz', 1700: 'numeric',
}
return types[oid] ?? `type_${oid}`
}
class ToolError extends Error {
constructor(message: string) {
super(message)
this.name = 'ToolError'
}
}
Tool 定义关键要素
inputSchema 设计原则:
✓ 每个字段都有清晰的 description(Agent 用它决定如何调用工具)
✓ 必填字段在 required 数组中声明
✓ 有合理的 default 值(减少 Agent 调用时的歧义)
✓ 有适当的约束(minimum/maximum/enum 等)
✗ 不要让 Agent 猜测字段格式——description 要精确
24.4 Provider Plugin 实战:接入一个新 LLM
需求描述
接入一个假想的 "Nebula AI" LLM 服务,该服务提供 OpenAI 兼容的 REST API,但有自己的认证方案(HMAC 签名)和特殊的模型能力配置。
完整实现
// src/index.ts
import type { PluginApi, LLMRequest, StreamChunk } from '@openclaw/plugin-api'
import crypto from 'crypto'
export async function setup(api: PluginApi) {
const config = api.getConfig<{
baseUrl: string
apiKey: string
secretKey: string // 用于 HMAC 签名
organizationId?: string
}>()
api.registerProvider({
id: 'nebula-ai',
label: 'Nebula AI',
// ==========================================
// 阶段1:配置物化
// ==========================================
catalog: async () => ({
models: [
{
id: 'nebula-ai/nebula-fast',
displayName: 'Nebula Fast',
description: 'Optimized for speed, ideal for interactive use',
contextWindow: 64000,
capabilities: {
streaming: true,
functionCalling: true,
vision: false,
},
pricing: {
inputPerMToken: 0.30,
outputPerMToken: 0.80,
}
},
{
id: 'nebula-ai/nebula-pro',
displayName: 'Nebula Pro',
description: 'Maximum capability for complex reasoning tasks',
contextWindow: 256000,
capabilities: {
streaming: true,
functionCalling: true,
vision: true,
},
pricing: {
inputPerMToken: 2.00,
outputPerMToken: 6.00,
}
},
]
}),
applyConfigDefaults: (config) => ({
...config,
defaultModel: config.defaultModel ?? 'nebula-ai/nebula-fast',
maxRetries: config.maxRetries ?? 3,
}),
// ==========================================
// 阶段2:模型解析
// ==========================================
normalizeModelId: (modelId) => {
// 处理简写别名
const aliases: Record<string, string> = {
'nebula-fast': 'nebula-ai/nebula-fast',
'fast': 'nebula-ai/nebula-fast',
'nebula-pro': 'nebula-ai/nebula-pro',
'pro': 'nebula-ai/nebula-pro',
}
return aliases[modelId] ?? null
},
normalizeTransport: (modelId) => ({
type: 'openai-compat',
baseUrl: config.baseUrl,
// nebula-pro 使用更长的超时
timeoutMs: modelId === 'nebula-ai/nebula-pro' ? 120000 : 30000,
}),
normalizeConfig: (rawConfig, modelId) => ({
...rawConfig,
// Nebula API 使用 max_completion_tokens 而非 max_tokens
max_completion_tokens: rawConfig.maxOutputTokens ?? 4096,
// Nebula Pro 支持 extended_thinking
...(modelId === 'nebula-ai/nebula-pro' && {
extended_thinking: rawConfig.thinkingBudget ? {
type: 'enabled',
budget_tokens: rawConfig.thinkingBudget,
} : undefined,
}),
}),
// ==========================================
// 阶段3:认证解析
// ==========================================
resolveConfigApiKey: () => config.apiKey,
// ==========================================
// 阶段4:模型准备
// ==========================================
capabilities: (modelId) => ({
streaming: true,
functionCalling: true,
vision: modelId === 'nebula-ai/nebula-pro',
contextWindow: modelId === 'nebula-ai/nebula-pro' ? 256000 : 64000,
outputFormats: ['text', 'json'],
// nebula-fast 不支持 parallel tool calls
parallelToolCalls: modelId !== 'nebula-ai/nebula-fast',
}),
contributeResolvedModelCompat: (modelId) => {
if (modelId === 'nebula-ai/nebula-fast') {
return {
// nebula-fast 的工具 schema 不支持 additionalProperties
toolSchemaStrict: false,
}
}
return null
},
// ==========================================
// 阶段6:流式传输(核心实现)
// ==========================================
createStreamFn: (cfg, auth) => {
return async function* nebulaStream(request: LLMRequest): AsyncIterable<StreamChunk> {
// 构建请求体
const body = {
model: request.modelId.replace('nebula-ai/', ''), // 去掉前缀
messages: request.messages,
stream: true,
max_completion_tokens: request.maxOutputTokens ?? 4096,
temperature: request.temperature ?? 0.7,
tools: request.tools?.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.inputSchema,
},
})),
}
const bodyStr = JSON.stringify(body)
const timestamp = Date.now().toString()
// Nebula API 使用 HMAC-SHA256 签名认证
const signature = computeHmacSignature(
bodyStr,
timestamp,
config.secretKey
)
const response = await fetch(`${config.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `ApiKey ${auth.apiKey}`,
'X-Nebula-Timestamp': timestamp,
'X-Nebula-Signature': signature,
...(config.organizationId && {
'X-Nebula-Org': config.organizationId,
}),
},
body: bodyStr,
})
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}))
throw new ProviderError(
`Nebula API error ${response.status}: ${errorBody.error?.message ?? 'Unknown error'}`,
response.status,
errorBody
)
}
// 解析 SSE 流
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? '' // 保留可能不完整的最后一行
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') return
let parsed: any
try {
parsed = JSON.parse(data)
} catch {
continue
}
const choice = parsed.choices?.[0]
if (!choice) continue
const delta = choice.delta
// 文本内容
if (delta?.content) {
yield { type: 'text', content: delta.content }
}
// 工具调用
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
yield {
type: 'tool_call',
toolCallId: toolCall.id,
toolName: toolCall.function?.name,
argumentsDelta: toolCall.function?.arguments,
}
}
}
// 使用量统计(流结束时)
if (choice.finish_reason && parsed.usage) {
yield {
type: 'usage',
inputTokens: parsed.usage.prompt_tokens,
outputTokens: parsed.usage.completion_tokens,
}
}
}
}
}
},
// ==========================================
// 阶段7:运行时
// ==========================================
buildReplayPolicy: (error, request) => {
const status = (error as any).status
if (status === 429) {
// Nebula 在响应头中提供 Retry-After
const retryAfter = parseInt((error as any).headers?.['retry-after'] ?? '5')
return {
shouldRetry: true,
delayMs: retryAfter * 1000,
maxAttempts: 3,
}
}
if (status >= 500 && status < 600) {
return { shouldRetry: true, delayMs: 1000, maxAttempts: 2 }
}
return { shouldRetry: false }
},
})
}
// HMAC-SHA256 签名计算
function computeHmacSignature(body: string, timestamp: string, secret: string): string {
const message = `${timestamp}.${body}`
return crypto.createHmac('sha256', secret).update(message).digest('hex')
}
class ProviderError extends Error {
status: number
body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.name = 'ProviderError'
this.status = status
this.body = body
}
}
24.5 jiti 开发模式 vs 构建发布模式
开发阶段工作流
# 1. 安装依赖
npm install
# 2. 在 openclaw.config.yaml 中注册 Plugin(开发路径)
cat >> ~/.openclaw/config.yaml << EOF
plugins:
- path: /path/to/my-plugin
dev: true # 启用 devEntryPoint + jiti
EOF
# 3. 启动 OpenClaw 开发模式
openclaw --dev
# 4. 修改 src/index.ts 后热重载
openclaw plugin reload my-plugin-id
# 5. 查看 Plugin 状态
openclaw plugin status my-plugin-id
生产构建流程
# 1. 运行 TypeScript 编译
npm run build
# 生成:
# dist/index.js ← 编译后的 JS
# dist/index.d.ts ← 类型声明文件
# dist/index.js.map ← Source Map(调试用)
# 2. 本地测试生产构建
openclaw plugin install /path/to/my-plugin
# 3. 验证安装
openclaw plugin list
openclaw plugin verify my-plugin-id
开发 vs 生产 对比
| 特性 | 开发模式(jiti) | 生产模式(native loader) |
|---|---|---|
| 入口文件 | src/index.ts |
dist/index.js |
| 需要编译步骤 | 否 | 是 |
| 热重载 | 支持 | 需要重启 |
| 启动速度 | 较慢(jiti 编译) | 快速 |
| 类型检查 | 运行时检查 | 编译时检查 |
| 适合场景 | Plugin 开发期间 | 生产部署 |
24.6 Plugin 发布到 npm 的流程
# 1. 确认 package.json 正确配置
# - name: @yourscope/openclaw-plugin-xxx
# - main: dist/index.js
# - files: ["dist", "plugin.manifest.yaml", "README.md"]
# 2. 构建
npm run build
# 3. 本地验证
openclaw plugin install .
openclaw plugin verify @yourscope/openclaw-plugin-xxx
# 4. 登录 npm
npm login
# 5. 发布
npm publish --access public
# 6. 用户安装方式
openclaw plugin install @yourscope/openclaw-plugin-xxx
package.json 发布配置
{
"name": "@myorg/openclaw-plugin-nebula",
"version": "1.0.0",
"files": [
"dist",
"plugin.manifest.yaml",
"README.md"
],
"keywords": [
"openclaw",
"openclaw-plugin",
"llm-provider",
"nebula-ai"
],
"openclaw": {
"pluginType": "provider",
"compatibleApiVersions": ["2.0"]
}
}
keywords 中包含 openclaw-plugin 是约定,让用户能通过 openclaw plugin search 在 npm 上找到你的 Plugin。
24.7 常见陷阱
陷阱1:直接修改全局状态
// ❌ 错误:在 Plugin 内部修改全局变量
import { globalRegistry } from '@openclaw/core' // 这个 import 根本不应该存在
globalRegistry.providers.set('my-provider', ...) // 绕过了 Plugin 加载管道
// ✓ 正确:通过 api 注册
api.registerProvider({
id: 'my-provider',
// ...
})
陷阱2:在 setup 之外存储 config 引用
// ❌ 错误:在模块顶层存储 config(config 在 setup 调用前不存在)
let globalConfig: any // 危险的模块级变量
export function setup(api: PluginApi) {
globalConfig = api.getConfig() // 如果 setup 被多次调用(如热重载),会有竞态
}
// ✓ 正确:将 config 传递到需要的地方
export function setup(api: PluginApi) {
const config = api.getConfig() // 局部变量,通过闭包传递
api.registerProvider({
createStreamFn: (cfg, auth) => {
// config 通过闭包安全访问
return async function* (request) { /* ... */ }
}
})
}
陷阱3:忽略穿透规则导致 normalizeModelId 拦截所有请求
// ❌ 错误:非 Provider Plugin 的 normalizeModelId 拦截了所有模型
api.registerProvider({
id: 'my-logging-plugin',
normalizeModelId: (modelId) => {
console.log('Model requested:', modelId)
return modelId // ← 错误!返回非 null 值会阻止穿透
}
})
// ✓ 正确:非 Provider Plugin 不应实现 normalizeModelId
// 如果需要日志,使用 wrapStreamFn
api.registerProvider({
id: 'my-logging-plugin',
wrapStreamFn: (originalStream) => {
return async function* (request) {
console.log('Request for model:', request.modelId)
yield* originalStream(request)
}
}
})
陷阱4:在 handler 中泄漏 Promise(忘记 await)
// ❌ 错误:未 await 的异步操作
handler: async (input, ctx) => {
pool.query(input.query) // 忘记 await!
return { status: 'done' } // 立即返回,查询在后台运行
}
// ✓ 正确:
handler: async (input, ctx) => {
const result = await pool.query(input.query) // 等待完成
return { rows: result.rows }
}
24.8 本章小结
三种 Plugin 类型的核心差异:
| 类型 | 核心 API | 必须实现的 Hook | 主要作用 |
|---|---|---|---|
| Channel | registerChannel |
onInit, sendMessage |
新增消息平台入口 |
| Tool | registerTool |
handler |
为 Agent 提供新工具 |
| Provider | registerProvider |
catalog, createStreamFn |
接入新 LLM Provider |
jiti 开发模式大幅降低了 Plugin 开发的迭代成本——修改源码后无需编译即可看到效果。生产发布前一定要切换到 native loader 模式验证构建产物的正确性。
下一章将站到更高视角,讨论 Skills 和 Plugin 的决策框架:给定一个需求,你怎么判断该用哪种方式实现?