本文是\”个人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)
- 返回格式:城市名 + 温度 + 天气状况 + 穿衣建议



