feat(v0.2.2): 插件热重载 — watchdog 监听 plugins/ 自动重载

- PluginService.start() 中启动 watchdog Observer
- 监听 plugins/ 下 .py/.yaml/.yml 文件变更
- 1秒防抖,避免重复触发
- 变更后自动卸载→重新加载对应插件
- 插件重载后自动重连网络路由
- PluginService.stop() 清理 observer
- 配置项 hot_reload: true/false 控制

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-13 11:01:00 +08:00
parent dcef1173f2
commit d5388057d9
2 changed files with 134 additions and 19 deletions
+3 -14
View File
@@ -9,10 +9,6 @@ commands:
permissions: permissions:
- framework.scaffold.plugin - framework.scaffold.plugin
source: internal source: internal
echo: &id001
description: echo input
permissions: []
source: plugin.example_plugin
help: help:
description: 显示帮助信息 description: 显示帮助信息
permissions: permissions:
@@ -33,10 +29,6 @@ commands:
permissions: permissions:
- framework.permission.read - framework.permission.read
source: internal source: internal
plugin_status: &id002
description: show status
permissions: []
source: plugin.example_plugin
pm_plugin_status: pm_plugin_status:
description: '权限管理: 查看插件权限状态' description: '权限管理: 查看插件权限状态'
permissions: permissions:
@@ -92,9 +84,6 @@ commands:
permissions: permissions:
- framework.command.test - framework.command.test
source: internal source: internal
last_updated: 316707.28385033 last_updated: 1742.472300949
plugin_commands: plugin_commands: {}
example_plugin: total_commands: 17
echo: *id001
plugin_status: *id002
total_commands: 19
+131 -5
View File
@@ -5,7 +5,9 @@ import logging
import asyncio import asyncio
import importlib.util import importlib.util
import sys import sys
import os
import inspect import inspect
import time
from pathlib import Path from pathlib import Path
from typing import Dict, List, Any, Optional, Callable from typing import Dict, List, Any, Optional, Callable
from dataclasses import dataclass from dataclasses import dataclass
@@ -49,22 +51,146 @@ class PluginService:
"""启动插件服务""" """启动插件服务"""
try: try:
logger.info("启动插件服务") logger.info("启动插件服务")
# 创建插件目录 # 创建插件目录
self.plugins_dir.mkdir(exist_ok=True) self.plugins_dir.mkdir(exist_ok=True)
# 自动加载插件 # 自动加载插件
if self.config['plugins']['auto_load']: if self.config['plugins']['auto_load']:
await self.load_all_plugins() await self.load_all_plugins()
await self.save_command_config() await self.save_command_config()
# 启动 watchdog 热重载
if self.config.get('plugins', {}).get('hot_reload', False):
self._loop = asyncio.get_running_loop()
self._start_watchdog()
self.is_running = True self.is_running = True
logger.info("插件服务启动完成") logger.info("插件服务启动完成")
except Exception as e: except Exception as e:
logger.error(f"启动插件服务时出错: {str(e)}", exc_info=True) logger.error(f"启动插件服务时出错: {str(e)}", exc_info=True)
raise raise
# ── watchdog 热重载 ──────────────────────────────────────
def _start_watchdog(self):
"""启动 watchdog 监听 plugins/ 目录实现热重载"""
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
plugin_service = self # 闭包引用
class _PluginReloadHandler(FileSystemEventHandler):
"""防抖 + 插件级重载"""
def __init__(self):
self._debounce: Dict[str, float] = {}
self._debounce_sec = 1.0 # 1 秒内同插件只触发一次
def _plugin_name_from_path(self, event_path: str) -> Optional[str]:
"""从事件路径提取插件名(plugins/<name>/..."""
try:
rel = os.path.relpath(event_path, str(plugin_service.plugins_dir))
parts = Path(rel).parts
if parts and not parts[0].startswith('.'):
return parts[0]
except ValueError:
pass
return None
def _should_handle(self, plugin_name: str) -> bool:
"""防抖 — 同插件在冷却时间内跳过"""
now = time.time()
last = self._debounce.get(plugin_name, 0)
if now - last < self._debounce_sec:
return False
self._debounce[plugin_name] = now
return True
def on_modified(self, event):
if event.is_directory:
return
path = event.src_path
if not path.endswith(('.py', '.yaml', '.yml')):
return
plugin_name = self._plugin_name_from_path(path)
if not plugin_name:
return
if not self._should_handle(plugin_name):
return
logger.info(
f"🔁 检测到插件文件变更: {plugin_name} ({os.path.basename(path)})"
)
asyncio.run_coroutine_threadsafe(
plugin_service._reload_plugin(plugin_name),
plugin_service._loop,
)
def on_created(self, event):
self.on_modified(event)
self._watchdog_observer = Observer()
self._watchdog_observer.schedule(
_PluginReloadHandler(),
str(self.plugins_dir),
recursive=True,
)
self._watchdog_observer.start()
logger.info("👁️ 插件热重载已启动 (watchdog)")
except ImportError:
logger.warning(
"watchdog 未安装,插件热重载不可用。pip install watchdog"
)
except Exception as e:
logger.warning(f"启动 watchdog 失败 (不影响框架): {e}")
async def _reload_plugin(self, plugin_name: str):
"""热重载单个插件 — 卸载后重新加载"""
plugin_dir = self.plugins_dir / plugin_name
if not plugin_dir.is_dir():
logger.debug(f"插件目录已消失,跳过重载: {plugin_name}")
return
if plugin_name in self.plugins:
logger.info(f" ⏳ 卸载旧版本: {plugin_name}")
await self.unload_plugin(plugin_name)
await asyncio.sleep(0.2) # 给文件系统缓冲
success = await self.load_plugin(plugin_name)
if success:
# 重载后重新注册延迟路由
try:
internet = self.service_manager.get_service("internet")
if internet and internet.is_running:
plugin = self.plugins.get(plugin_name)
if plugin and hasattr(plugin, 'network_bridge'):
await self._reinitialize_plugin_network(plugin, internet)
except Exception:
pass
logger.info(f" ✅ 热重载完成: {plugin_name}")
else:
logger.warning(f" ❌ 热重载失败: {plugin_name}")
def _stop_watchdog(self):
"""停止 watchdog observer"""
obs = getattr(self, '_watchdog_observer', None)
if obs and obs.is_alive():
obs.stop()
obs.join(timeout=3)
logger.info("👁️ 插件热重载已停止")
async def stop(self):
"""停止插件服务"""
logger.info("关闭插件服务")
self._stop_watchdog()
for name in list(self.plugins.keys()):
await self.unload_plugin(name)
self.is_running = False
async def load_all_plugins(self): async def load_all_plugins(self):
"""加载所有插件""" """加载所有插件"""