文章链接:https://blog.csdn.net/qq_43201350/article/details/163717377
本文是"个人AI助手平台"系列第五篇。建议先阅读前四篇了解基础架构后再看本文。
一、为什么需要插件
前面的文章里我们搭好了架构、部署上了云、打通了四个IM通道。但一个只会聊天的 AI 助手太单薄了——用户问"明天会下雨吗",你需要查询天气;用户说"帮我记下这条待办",你需要持久化存储。
插件的价值 :
-
能力扩展 :让 AI 能查天气、做翻译、管理待办——不再是一个只会聊天的花瓶
-
热加载 :新增能力不用重启服务,开发者写好插件、丢进插件目录就生效
-
社区生态 :参照 VS Code 插件市场模式,任何人都可以贡献插件
本文带你从零写出三个完整可运行的插件,并掌握插件开发范式。
二、插件规范(Plugin Specification)
先定义插件接口——所有插件必须遵守的契约:
// plugin-spec.ts
export interface PluginManifest {
/** 插件唯一标识,如 "weather" */
id: string;
/** 显示名称 */
name: string;
/** 版本号 */
version: string;
/** 作者 */
author: string;
/** 一句话描述 */
description: string;
/** 所需权限 */
permissions: string[];
/** 触发关键词,如 ["天气", "weather"] */
triggers: string[];
}
export interface PluginContext {
/** 用户ID */
userId: string;
/** 所属群聊ID */
channelId?: string;
/** 插件数据存储路径(持久化用) */
dataDir: string;
/** 日志函数 */
logger: PluginLogger;
/** 发送消息到当前通道 */
sendMessage: (text: string) => Promise<void>;
}
export interface PluginLogger {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string, err?: Error) => void;
}
/**
* 插件基类——所有插件必须继承
*/
export abstract class BasePlugin {
abstract readonly manifest: PluginManifest;
/**
* 初始化(插件加载时调用一次)
*/
abstract onInit(ctx: PluginContext): Promise<void>;
/**
* 收到消息时调用
* @returns true 表示已处理该消息(不再向下传递)
*/
abstract onMessage(
message: string,
ctx: PluginContext
): Promise<{ handled: boolean; reply?: string }>;
/**
* 卸载插件时调用(清理资源)
*/
abstract onDestroy(): Promise<void>;
}三、插件管理器(动态加载)
插件管理器负责扫描插件目录、加载、卸载、热更新:
// plugin-manager.ts
import * as fs from 'fs';
import * as path from 'path';
import { BasePlugin, PluginContext, PluginManifest } from './plugin-spec';
interface LoadedPlugin {
instance: BasePlugin;
manifest: PluginManifest;
filePath: string;
lastModified: number;
}
export class PluginManager {
private plugins: Map<string, LoadedPlugin> = new Map();
private ctx: PluginContext;
private hotWatchInterval: NodeJS.Timeout | null = null;
constructor(ctx: PluginContext, private pluginsDir: string) {
this.ctx = ctx;
}
/**
* 启动:扫描并加载所有插件、启动热监听
*/
async start(): Promise<void> {
await this.loadAll();
this.startHotWatch();
this.ctx.logger.info(`插件管理器已启动,加载 ${this.plugins.size} 个插件`);
}
/**
* 停止:卸载所有插件
*/
async stop(): Promise<void> {
if (this.hotWatchInterval) clearInterval(this.hotWatchInterval);
for (const [id, loaded] of this.plugins) {
await loaded.instance.onDestroy();
this.ctx.logger.info(`插件 ${id} 已卸载`);
}
this.plugins.clear();
}
/**
* 处理消息——遍历所有插件,第一个处理的生效
*/
async handleMessage(message: string): Promise<string | null> {
for (const [id, loaded] of this.plugins) {
try {
const result = await loaded.instance.onMessage(message, this.ctx);
if (result.handled && result.reply) {
this.ctx.logger.info(`插件 [${id}] 处理了消息`);
return result.reply;
}
} catch (err) {
this.ctx.logger.error(`插件 [${id}] 处理消息出错`, err as Error);
}
}
return null; // 无插件处理
}
/**
* 扫描并加载所有插件
*/
private async loadAll(): Promise<void> {
if (!fs.existsSync(this.pluginsDir)) {
fs.mkdirSync(this.pluginsDir, { recursive: true });
return;
}
const files = fs.readdirSync(this.pluginsDir);
for (const file of files) {
if (file.endsWith('.js') || file.endsWith('.ts')) {
await this.loadPlugin(path.join(this.pluginsDir, file));
}
}
}
/**
* 加载单个插件
*/
private async loadPlugin(filePath: string): Promise<void> {
try {
// 清除 require 缓存以支持热更新
const resolvedPath = require.resolve(filePath);
delete require.cache[resolvedPath];
const PluginClass = require(filePath).default;
const instance: BasePlugin = new PluginClass();
const manifest = instance.manifest;
if (this.plugins.has(manifest.id)) {
this.ctx.logger.warn(`插件 ${manifest.id} 已存在,跳过重复加载`);
return;
}
await instance.onInit(this.ctx);
const stats = fs.statSync(filePath);
this.plugins.set(manifest.id, {
instance,
manifest,
filePath,
lastModified: stats.mtimeMs,
});
this.ctx.logger.info(`插件 [${manifest.id}] v${manifest.version} 加载成功`);
} catch (err) {
this.ctx.logger.error(`加载插件失败: ${filePath}`, err as Error);
}
}
/**
* 启动热监听:每 3 秒检查插件文件是否变化
*/
private startHotWatch(): void {
this.hotWatchInterval = setInterval(async () => {
for (const [id, loaded] of this.plugins) {
try {
const stats = fs.statSync(loaded.filePath);
if (stats.mtimeMs > loaded.lastModified) {
this.ctx.logger.info(`检测到插件 [${id}] 文件变化,热更新中...`);
await loaded.instance.onDestroy();
this.plugins.delete(id);
await this.loadPlugin(loaded.filePath);
}
} catch (err) {
// 文件可能被删除,忽略
}
}
}, 3000);
}
/**
* 获取已加载插件列表
*/
getLoadedPlugins(): PluginManifest[] {
return Array.from(this.plugins.values()).map(p => p.manifest);
}
}四、实战一:天气查询插件
4.1 设计思路
-
触发词 :
天气、weather、会下雨、多少度 -
依赖 :调用公开天气 API(Open-Meteo,免费无需 Key)
-
返回格式 :城市名 + 温度 + 天气状况 + 穿衣建议
4.2 完整代码
// weather-plugin.ts
import { BasePlugin, PluginContext, PluginManifest } from './plugin-spec';
interface WeatherData {
city: string;
temperature: number;
weatherCode: number;
humidity: number;
windSpeed: number;
}
export default class WeatherPlugin extends BasePlugin {
readonly manifest: PluginManifest = {
id: 'weather',
name: '天气查询',
version: '1.0.0',
author: 'demo',
description: '查询指定城市的实时天气',
permissions: ['network'],
triggers: ['天气', 'weather', '温度', '下雨', '下雪'],
};
// 城市名 → 经纬度 映射(简单版,生产环境应调用地理编码API)
private cityCoords: Record<string, [number, number]> = {
'北京': [39.9042, 116.4074],
'上海': [31.2304, 121.4737],
'广州': [23.1291, 113.2644],
'深圳': [22.5431, 114.0579],
'杭州': [30.2741, 120.1551],
'成都': [30.5728, 104.0668],
'武汉': [30.5928, 114.3055],
'南京': [32.0603, 118.7969],
'东京': [35.6762, 139.6503],
'纽约': [40.7128, -74.0060],
'伦敦': [51.5074, -0.1278],
'巴黎': [48.8566, 2.3522],
'新加坡': [1.3521, 103.8198],
};
private ctx!: PluginContext;
// 天气码 → 中文描述 + emoji
private weatherDesc: Record<number, string> = {
0: '☀️ 晴天',
1: '🌤 大部晴朗',
2: '⛅ 多云',
3: '☁️ 阴天',
45: '🌫 雾',
51: '🌧 小雨',
61: '🌧 中雨',
63: '🌧 大雨',
71: '❄️ 小雪',
75: '❄️ 大雪',
80: '🌦 阵雨',
95: '⛈ 雷暴',
};
async onInit(ctx: PluginContext): Promise<void> {
this.ctx = ctx;
ctx.logger.info('天气插件初始化完成');
}
async onDestroy(): Promise<void> {
// 无需要清理的资源
}
async onMessage(
message: string,
ctx: PluginContext
): Promise<{ handled: boolean; reply?: string }> {
const matched = this.matchTrigger(message);
if (!matched) return { handled: false };
const city = this.extractCity(message);
if (!city) {
return {
handled: true,
reply: '📭 请指定城市,例如:"北京今天天气怎么样"',
};
}
const coords = this.cityCoords[city];
if (!coords) {
return {
handled: true,
reply: `🤔 暂时不支持"${city}"的天气查询,请尝试其他城市~`,
};
}
try {
const data = await this.fetchWeather(coords[0], coords[1]);
const reply = this.formatReply(city, data);
return { handled: true, reply };
} catch (err) {
ctx.logger.error('天气查询失败', err as Error);
return { handled: true, reply: '😵 天气查询暂时不可用,请稍后再试' };
}
}
/**
* 匹配触发关键词
*/
private matchTrigger(message: string): boolean {
const lower = message.toLowerCase();
return this.manifest.triggers.some(t => lower.includes(t));
}
/**
* 从消息中提取城市名
* 策略:遍历城市列表,第一个出现在消息中的即为目标城市
*/
private extractCity(message: string): string | null {
for (const city of Object.keys(this.cityCoords)) {
if (message.includes(city)) return city;
}
return null;
}
/**
* 调用 Open-Meteo 免费天气 API
*/
private async fetchWeather(lat: number, lon: number): Promise<WeatherData> {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m`;
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`天气API返回 ${res.status}`);
const json = await res.json();
return {
city: '',
temperature: json.current.temperature_2m,
weatherCode: json.current.weather_code,
humidity: json.current.relative_humidity_2m,
windSpeed: json.current.wind_speed_10m,
};
}
/**
* 格式化回复消息
*/
private formatReply(city: string, data: WeatherData): string {
const desc = this.weatherDesc[data.weatherCode] || '未知';
const advice = this.getAdvice(data.temperature, data.weatherCode);
return [
`📍 **${city}天气**`,
``,
`🌡 温度:**${data.temperature}°C**`,
`💧 湿度:${data.humidity}%`,
`🌬 风力:${data.windSpeed} km/h`,
`天气:${desc}`,
``,
`💡 ${advice}`,
].join('\n');
}
/**
* 简单穿衣建议
*/
private getAdvice(temp: number, weatherCode: number): string {
const parts: string[] = [];
if (temp < 10) parts.push('天气寒冷,记得穿羽绒服🧥');
else if (temp < 20) parts.push('温度适中,建议穿外套🧣');
else if (temp < 30) parts.push('天气舒适,短袖就够了👕');
else parts.push('天气炎热,注意防晒防暑🌞');
if ([51, 61, 63, 80].includes(weatherCode)) parts.push('出门记得带伞🌂');
if (weatherCode === 45) parts.push('能见度低,开车注意安全🚗');
return parts.join(';');
}
}4.3 运行效果
用户:上海明天天气怎么样
AI: 📍 上海天气
🌡 温度:28°C
💧 湿度:65%
🌬 风力:12 km/h
天气:⛅ 多云
💡 天气舒适,短袖就够了👕
五、实战二:翻译插件
5.1 设计思路
-
触发词 :
翻译 -
格式 :
翻译 [源语言→目标语言] 文本(语言参数可选,默认中文↔英文) -
依赖 :调免费翻译 API(Lingva,无 Key 需要)
5.2 完整代码
// translate-plugin.ts
import { BasePlugin, PluginContext, PluginManifest } from './plugin-spec';
interface LangPair {
source: string;
target: string;
label: string;
}
export default class TranslatePlugin extends BasePlugin {
readonly manifest: PluginManifest = {
id: 'translate',
name: '翻译助手',
version: '1.0.0',
author: 'demo',
description: '多语言翻译,支持中/英/日/韩/法/德',
permissions: ['network'],
triggers: ['翻译', 'translate'],
};
// 语言简称 → 名称映射
private langMap: Record<string, string> = {
'中': 'zh', '中文': 'zh', 'cn': 'zh', 'zh': 'zh',
'英': 'en', '英文': 'en', 'en': 'en',
'日': 'ja', '日文': 'ja', '日语': 'ja', 'ja': 'ja',
'韩': 'ko', '韩文': 'ko', '韩语': 'ko', 'ko': 'ko',
'法': 'fr', '法文': 'fr', '法语': 'fr', 'fr': 'fr',
'德': 'de', '德文': 'de', '德语': 'de', 'de': 'de',
'俄': 'ru', '俄文': 'ru', '俄语': 'ru', 'ru': 'ru',
'西': 'es', '西班牙': 'es', 'es': 'es',
};
private ctx!: PluginContext;
async onInit(ctx: PluginContext): Promise<void> {
this.ctx = ctx;
ctx.logger.info('翻译插件初始化完成');
}
async onDestroy(): Promise<void> {}
async onMessage(
message: string,
ctx: PluginContext
): Promise<{ handled: boolean; reply?: string }> {
if (!message.includes('翻译') && !message.toLowerCase().includes('translate')) {
return { handled: false };
}
const parsed = this.parseMessage(message);
if (!parsed) {
return {
handled: true,
reply: [
'📝 翻译助手使用方法:',
'',
'• `翻译 [语言→语言] 文本`',
'• `翻译 文本` (自动中英互译)',
'',
'示例:',
'• `翻译 中→英 你好世界`',
'• `翻译 Hello, how are you?`',
'• `翻译 日→中 こんにちは`',
].join('\n'),
};
}
try {
const result = await this.doTranslate(
parsed.text,
parsed.source,
parsed.target
);
return {
handled: true,
reply: [
`🌐 翻译结果 (${parsed.sourceLabel} → ${parsed.targetLabel})`,
'',
`原文:${parsed.text}`,
`译文:**${result}**`,
].join('\n'),
};
} catch (err) {
ctx.logger.error('翻译失败', err as Error);
return { handled: true, reply: '😵 翻译服务暂时不可用,请稍后再试' };
}
}
/**
* 解析用户消息
* 格式:翻译 中→英 文本 / 翻译 文本
*/
private parseMessage(message: string): {
text: string;
source: string;
target: string;
sourceLabel: string;
targetLabel: string;
} | null {
// 去掉"翻译"关键词
let content = message.replace(/^(翻译|translate)\s*/i, '').trim();
if (!content) return null;
// 尝试匹配 "语言→语言 文本" 格式
const langPattern = /^([\u4e00-\u9fa5]+|en|ja|ko|fr|de|ru|es)\s*[-→>to]+\s*([\u4e00-\u9fa5]+|en|ja|ko|fr|de|ru|es)\s+(.+)$/i;
const langMatch = content.match(langPattern);
if (langMatch) {
const srcLang = this.langMap[langMatch[1].toLowerCase()] || 'auto';
const tgtLang = this.langMap[langMatch[2].toLowerCase()] || 'en';
return {
text: langMatch[3].trim(),
source: srcLang,
target: tgtLang,
sourceLabel: langMatch[1],
targetLabel: langMatch[2],
};
}
// 不指定语言 → 自动检测
const isChinese = /[\u4e00-\u9fa5]/.test(content);
return {
text: content,
source: 'auto',
target: isChinese ? 'en' : 'zh',
sourceLabel: isChinese ? '中文' : '自动检测',
targetLabel: isChinese ? '英文' : '中文',
};
}
/**
* 执行翻译(调用 Lingva API)
*/
private async doTranslate(text: string, source: string, target: string): Promise<string> {
const url = `https://lingva.ml/api/v1/${source}/${target}/${encodeURIComponent(text)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`翻译API返回 ${res.status}`);
const json = await res.json();
return json.translation;
}
}5.3 运行效果
用户:翻译 中→日 天气不错
AI: 🌐 翻译结果 (中 → 日)
原文:天气不错
译文:いい天気ですね
用户:翻译 What's the weather like in Tokyo?
AI: 🌐 翻译结果 (自动检测 → 中文)
原文:What's the weather like in Tokyo?
译文:东京的天气怎么样?
六、实战三:待办管理插件
6.1 设计思路
-
触发词 :
待办、todo、备忘 -
操作 :
添加/完成/列表/删除 -
存储 :JSON 文件持久化,按用户隔离
-
亮点 :支持自然语言解析——“明天下午3点给小李打电话” → 自动提取时间、内容
6.2 完整代码
// todo-plugin.ts
import { BasePlugin, PluginContext, PluginManifest } from './plugin-spec';
import * as fs from 'fs';
import * as path from 'path';
interface TodoItem {
id: string;
content: string;
createdAt: string;
completedAt: string | null;
dueDate: string | null;
priority: 'low' | 'medium' | 'high';
tags: string[];
}
interface TodoStore {
[userId: string]: TodoItem[];
}
export default class TodoPlugin extends BasePlugin {
readonly manifest: PluginManifest = {
id: 'todo',
name: '待办管理',
version: '1.0.0',
author: 'demo',
description: '待办事项管理:添加/完成/列表/删除',
permissions: ['storage'],
triggers: ['待办', 'todo', '备忘', '提醒我', '记一下'],
};
private store!: TodoStore;
private storePath!: string;
private ctx!: PluginContext;
async onInit(ctx: PluginContext): Promise<void> {
this.ctx = ctx;
this.storePath = path.join(ctx.dataDir, 'todos.json');
this.store = this.loadStore();
ctx.logger.info(`待办插件初始化完成,共加载 ${this.countAll()} 条待办`);
}
async onDestroy(): Promise<void> {
this.saveStore();
}
async onMessage(
message: string,
ctx: PluginContext
): Promise<{ handled: boolean; reply?: string }> {
if (!this.matchTrigger(message)) return { handled: false };
const userId = ctx.userId;
// 命令路由
if (/^查看|列表|所有|list/i.test(message)) {
return { handled: true, reply: this.listTodos(userId) };
}
if (/^(完成|done|✅).+/i.test(message)) {
return { handled: true, reply: await this.completeTodo(userId, message) };
}
if (/^(删除|delete|删).+/i.test(message)) {
return { handled: true, reply: await this.deleteTodo(userId, message) };
}
if (/^(添加|新增|add|待办|todo|备忘)/i.test(message)) {
return { handled: true, reply: await this.addTodo(userId, message) };
}
// 默认:尝试当作新增
return { handled: true, reply: await this.addTodo(userId, message) };
}
private matchTrigger(message: string): boolean {
const lower = message.toLowerCase();
return this.manifest.triggers.some(t => lower.includes(t)) ||
/^(待办|todo|备忘|查看|列表|完成|done|✅|删除|delete|添加|新增|add)/i.test(message);
}
/**
* 添加待办
*/
private async addTodo(userId: string, message: string): Promise<string> {
const content = this.extractContent(message);
if (!content) {
return '📝 请说明要记录的待办内容,例如:"待办 明天下午3点开会"';
}
if (!this.store[userId]) this.store[userId] = [];
const dueDate = this.extractDueDate(content);
const priority = this.extractPriority(content);
const tags = this.extractTags(content);
const item: TodoItem = {
id: this.generateId(),
content,
createdAt: new Date().toISOString(),
completedAt: null,
dueDate,
priority,
tags,
};
this.store[userId].push(item);
this.saveStore();
const parts = ['✅ 已添加待办:'];
parts.push(`📌 **${content}**`);
if (dueDate) parts.push(`⏰ 截止:${dueDate}`);
if (priority !== 'medium') parts.push(`🔴 优先级:${this.priorityLabel(priority)}`);
if (tags.length > 0) parts.push(`🏷 标签:${tags.join(', ')}`);
parts.push(`📋 当前共 ${this.store[userId].length} 条待办`);
return parts.join('\n');
}
/**
* 完成待办(按序号)
*/
private async completeTodo(userId: string, message: string): Promise<string> {
const numMatch = message.match(/(\d+)/);
if (!numMatch) return '请指定序号,例如:"完成 3"';
const index = parseInt(numMatch[1]) - 1;
const todos = this.store[userId];
if (!todos || index < 0 || index >= todos.length) {
return `❌ 序号 ${numMatch[1]} 不存在,当前共 ${todos?.length || 0} 条待办`;
}
const item = todos[index];
if (item.completedAt) return `⚠️ "${item.content}" 已经完成了`;
item.completedAt = new Date().toISOString();
this.saveStore();
return `🎉 已完成:**${item.content}**`;
}
/**
* 删除待办
*/
private async deleteTodo(userId: string, message: string): Promise<string> {
const numMatch = message.match(/(\d+)/);
if (!numMatch) return '请指定序号,例如:"删除 3"';
const index = parseInt(numMatch[1]) - 1;
const todos = this.store[userId];
if (!todos || index < 0 || index >= todos.length) {
return `❌ 序号 ${numMatch[1]} 不存在`;
}
const item = todos.splice(index, 1)[0];
this.saveStore();
return `🗑 已删除:**${item.content}**`;
}
/**
* 查看待办列表
*/
private listTodos(userId: string): string {
const todos = this.store[userId] || [];
if (todos.length === 0) return '📭 当前没有待办事项~';
const active = todos.filter(t => !t.completedAt);
const completed = todos.filter(t => t.completedAt);
const lines: string[] = ['📋 **待办列表**', ''];
if (active.length > 0) {
lines.push('⏳ 未完成:');
active.forEach((t, i) => {
const num = todos.indexOf(t) + 1;
const pri = t.priority !== 'medium' ? ` ${this.priorityEmoji(t.priority)}` : '';
const due = t.dueDate ? ` ⏰${t.dueDate}` : '';
lines.push(` ${num}. ${t.content}${pri}${due}`);
});
lines.push('');
}
if (completed.length > 0) {
lines.push('✅ 已完成:');
completed.slice(-3).forEach(t => {
lines.push(` ~~${t.content}~~`);
});
}
lines.push('');
lines.push(`📊 总计 ${todos.length} 条,未完成 ${active.length} 条`);
return lines.join('\n');
}
// ===== 辅助方法 =====
private extractContent(message: string): string | null {
return message
.replace(/^(添加|新增|add\s+|待办|todo|备忘|记一下|提醒我)\s*/i, '')
.trim() || null;
}
/**
* 尝试从内容中提取截止日期关键词
*/
private extractDueDate(content: string): string | null {
if (/明天/.test(content)) {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
}
if (/后天/.test(content)) {
const d = new Date();
d.setDate(d.getDate() + 2);
return d.toISOString().slice(0, 10);
}
const dateMatch = content.match(/(\d{4}[-/]\d{1,2}[-/]\d{1,2})/);
if (dateMatch) return dateMatch[1].replace(/\//g, '-');
return null;
}
private extractPriority(content: string): 'low' | 'medium' | 'high' {
if (/紧急|重要|高优|urgent|important|❗/.test(content)) return 'high';
if (/不急|低优|low/.test(content)) return 'low';
return 'medium';
}
private extractTags(content: string): string[] {
const tags: string[] = [];
const tagMatch = content.match(/#(\S+)/g);
if (tagMatch) {
tags.push(...tagMatch.map(t => t.slice(1)));
}
return tags;
}
private priorityLabel(p: string): string {
switch (p) {
case 'high': return '🔴 高';
case 'low': return '🟢 低';
default: return '🟡 中';
}
}
private priorityEmoji(p: string): string {
switch (p) {
case 'high': return '🔴';
case 'low': return '🟢';
default: return '';
}
}
private generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
private loadStore(): TodoStore {
try {
if (fs.existsSync(this.storePath)) {
return JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
}
} catch (err) {
this.ctx?.logger?.warn('加载待办数据失败,使用空数据');
}
return {};
}
private saveStore(): void {
try {
const dir = path.dirname(this.storePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.storePath, JSON.stringify(this.store, null, 2));
} catch (err) {
this.ctx?.logger?.error('保存待办数据失败', err as Error);
}
}
private countAll(): number {
return Object.values(this.store).reduce((sum, arr) => sum + arr.length, 0);
}
}6.3 运行效果
用户:待办 明天下午3点给客户发方案 #工作
AI: ✅ 已添加待办:
📌 明天下午3点给客户发方案
⏰ 截止:2026-06-10
🏷 标签:工作
📋 当前共 3 条待办
用户:查看待办
AI: 📋 待办列表
⏳ 未完成:
1. 买水果
2. 写周报
3. 明天下午3点给客户发方案 🏷工作
✅ 已完成:
~~检查服务器日志~~
📊 总计 4 条,未完成 3 条
用户:完成 2
AI: 🎉 已完成:写周报
七、三个插件的对比总结
| 维度 | 天气插件 | 翻译插件 | 待办插件 |
|---|---|---|---|
| 数据来源 | 外部 API | 外部 API | 本地 JSON |
| 权限需求 | network | network | storage |
| 触发方式 | 模糊匹配关键词 | 命令+自然语言 | 多命令路由 |
| 状态管理 | 无状态 | 无状态 | 有状态持久化 |
| 错误处理 | API 超时降级 | API 超时降级 | 文件读写容错 |
| 复杂度 | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
这三个插件覆盖了插件开发的主流范式,可以作为你自己写插件的模板。
八、插件开发最佳实践
8.1 触发词匹配策略
优先级从高到低:
1. 精确命令:/todo add xxx、/translate en→zh —— 最可靠
2. 关键词匹配:"天气"、"翻译" —— 兼顾自然
3. 兜底模式:未匹配到任何插件 → 交给 AI 自主回复
8.2 错误处理三板斧
try {
const result = await callExternalAPI(params);
return formatResult(result);
} catch (err) {
if (err.name === 'TimeoutError') {
return '⏳ 请求超时,请稍后重试'; // 超时降级
} else if (err.message.includes('429')) {
return '🔥 请求太频繁,请稍后再试'; // 限流降级
} else {
ctx.logger.error('未知错误', err);
return '😵 服务暂时不可用'; // 通用兜底
}
}8.3 优先级与互斥策略
多个插件可能匹配同一条消息,处理顺序很重要:
// 按优先级排序插件
const priorityOrder = ['todo', 'weather', 'translate']; // 待办优先,因为用户意图最明确
async handleMessage(message: string) {
for (const id of priorityOrder) {
const plugin = this.plugins.get(id);
if (plugin) {
const result = await plugin.instance.onMessage(message, ctx);
if (result.handled) return result.reply;
}
}
return null; // 无插件处理,交给AI
}8.4 参数校验防御
// 最脆弱的环节:用户输入
// 永远不要信任用户的输入
onMessage(message, ctx) {
const city = this.extractCity(message);
if (!city || city.length > 20) return fallback(); // 长度校验
if (!/^[\u4e00-\u9fa5\w\s-]+$/.test(city)) return fallback(); // 字符白名单
// ...
}8.5 插件开发 Checklist
在发布插件前,确保:
-
manifest信息完整(id/name/version/triggers) -
onInit不抛异常(哪怕数据目录不存在也能降级) -
onMessage始终返回{ handled, reply },不依赖外部插件 -
onDestroy清理资源(定时器/文件句柄/HTTP 连接) -
所有外部调用有超时(
AbortSignal.timeout) -
所有 I/O 操作有 try-catch
-
不修改
ctx传入对象 -
不阻塞事件循环(避免同步大计算)
九、收尾
三个插件从头到尾写完,你应该已经掌握了个人AI助手的插件开发范式。这套插件体系的特点是:
-
统一基类 :
BasePlugin约束了接口契约 -
热加载 :改完代码,3 秒后自动生效,无需重启
-
管道模式 :消息按优先级流过每个插件,第一个处理的生效
-
按需加载 :不需要的插件不编译、不依赖
下一篇预告 :系列终章——横评对比同类方案(Dify/Coze/FastGPT 等),帮你搞清楚各种 AI 助手平台的区别与选型依据。
本系列所有代码均为教学演示,生产环境请根据实际情况调整。