本文是"个人AI助手平台"系列第五篇。建议先阅读前四篇了解基础架构后再看本文。
一、为什么需要插件
前面的文章里我们搭好了架构、部署上了云、打通了四个IM通道。但一个只会聊天的 AI 助手太单薄了——用户问"明天会下雨吗",你需要查询天气;用户说"帮我记下这条待办",你需要持久化存储。
插件的价值:
- 能力扩展:让 AI 能查天气、做翻译、管理待办——不再是一个只会聊天的花瓶
- 热加载:新增能力不用重启服务,开发者写好插件、丢进插件目录就生效
- 社区生态:参照 VS Code 插件市场模式,任何人都可以贡献插件
本文带你从零写出三个完整可运行的插件,并掌握插件开发范式。
二、插件规范(Plugin Specification)
先定义插件接口——所有插件必须遵守的契约:
// plugin-spec.tsexportinterfacePluginManifest{/** 插件唯一标识,如 "weather" */id:string;/** 显示名称 */name:string;/** 版本号 */version:string;/** 作者 */author:string;/** 一句话描述 */description:string;/** 所需权限 */permissions:string[];/** 触发关键词,如 ["天气", "weather"] */triggers:string[];}exportinterfacePluginContext{/** 用户ID */userId:string;/** 所属群聊ID */channelId?:string;/** 插件数据存储路径(持久化用) */dataDir:string;/** 日志函数 */logger:PluginLogger;/** 发送消息到当前通道 */sendMessage:(text:string)=>Promise<void>;}exportinterfacePluginLogger{info:(msg:string)=>void;warn:(msg:string)=>void;error:(msg:string,err?:Error)=>void;}/** * 插件基类——所有插件必须继承 */exportabstractclassBasePlugin{abstractreadonlymanifest:PluginManifest;/** * 初始化(插件加载时调用一次) */abstractonInit(ctx:PluginContext):Promise<void>;/** * 收到消息时调用 * @returns true 表示已处理该消息(不再向下传递) */abstractonMessage(message:string,ctx:PluginContext):Promise<{handled:boolean;reply?:string}>;/** * 卸载插件时调用(清理资源) */abstractonDestroy():Promise<void>;}三、插件管理器(动态加载)
插件管理器负责扫描插件目录、加载、卸载、热更新:
// plugin-manager.tsimport*asfsfrom'fs';import*aspathfrom'path';import{BasePlugin,PluginContext,PluginManifest}from'./plugin-spec';interfaceLoadedPlugin{instance:BasePlugin;manifest:PluginManifest;filePath:string;lastModified:number;}exportclassPluginManager{privateplugins:Map<string,LoadedPlugin>=newMap();privatectx:PluginContext;privatehotWatchInterval:NodeJS.Timeout|null=null;constructor(ctx:PluginContext,privatepluginsDir:string){this.ctx=ctx;}/** * 启动:扫描并加载所有插件、启动热监听 */asyncstart():Promise<void>{awaitthis.loadAll();this.startHotWatch();this.ctx.logger.info(`插件管理器已启动,加载${this.plugins.size}个插件`);}/** * 停止:卸载所有插件 */asyncstop():Promise<void>{if(this.hotWatchInterval)clearInterval(this.hotWatchInterval);for(const[id,loaded]ofthis.plugins){awaitloaded.instance.onDestroy();this.ctx.logger.info(`插件${id}已卸载`);}this.plugins.clear();}/** * 处理消息——遍历所有插件,第一个处理的生效 */asynchandleMessage(message:string):Promise<string|null>{for(const[id,loaded]ofthis.plugins){try{constresult=awaitloaded.instance.onMessage(message,this.ctx);if(result.handled&&result.reply){this.ctx.logger.info(`插件 [${id}] 处理了消息`);returnresult.reply;}}catch(err){this.ctx.logger.error(`插件 [${id}] 处理消息出错`,errasError);}}returnnull;// 无插件处理}/** * 扫描并加载所有插件 */privateasyncloadAll():Promise<void>{if(!fs.existsSync(this.pluginsDir)){fs.mkdirSync(this.pluginsDir,{recursive:true});return;}constfiles=fs.readdirSync(this.pluginsDir);for(constfileoffiles){if(file.endsWith('.js')||file.endsWith('.ts')){awaitthis.loadPlugin(path.join(this.pluginsDir,file));}}}/** * 加载单个插件 */privateasyncloadPlugin(filePath:string):Promise<void>{try{// 清除 require 缓存以支持热更新constresolvedPath=require.resolve(filePath);deleterequire.cache[resolvedPath];constPluginClass=require(filePath).default;constinstance:BasePlugin=newPluginClass();constmanifest=instance.manifest;if(this.plugins.has(manifest.id)){this.ctx.logger.warn(`插件${manifest.id}已存在,跳过重复加载`);return;}awaitinstance.onInit(this.ctx);conststats=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}`,errasError);}}/** * 启动热监听:每 3 秒检查插件文件是否变化 */privatestartHotWatch():void{this.hotWatchInterval=setInterval(async()=>{for(const[id,loaded]ofthis.plugins){try{conststats=fs.statSync(loaded.filePath);if(stats.mtimeMs>loaded.lastModified){this.ctx.logger.info(`检测到插件 [${id}] 文件变化,热更新中...`);awaitloaded.instance.onDestroy();this.plugins.delete(id);awaitthis.loadPlugin(loaded.filePath);}}catch(err){// 文件可能被删除,忽略}}},3000);}/** * 获取已加载插件列表 */getLoadedPlugins():PluginManifest[]{returnArray.from(this.plugins.values()).map(p=>p.manifest);}}四、实战一:天气查询插件
4.1 设计思路
- 触发词:
天气、weather、会下雨、多少度 - 依赖:调用公开天气 API(Open-Meteo,免费无需 Key)
- 返回格式:城市名 + 温度 + 天气状况 + 穿衣建议