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
+131 -5
View File
@@ -5,7 +5,9 @@ import logging
import asyncio
import importlib.util
import sys
import os
import inspect
import time
from pathlib import Path
from typing import Dict, List, Any, Optional, Callable
from dataclasses import dataclass
@@ -49,22 +51,146 @@ class PluginService:
"""启动插件服务"""
try:
logger.info("启动插件服务")
# 创建插件目录
self.plugins_dir.mkdir(exist_ok=True)
# 自动加载插件
if self.config['plugins']['auto_load']:
await self.load_all_plugins()
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
logger.info("插件服务启动完成")
except Exception as e:
logger.error(f"启动插件服务时出错: {str(e)}", exc_info=True)
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):
"""加载所有插件"""