feat(plugins): 插件支持热加载与启用/停用清单

- pugins/__init__.py 重写:load_plugins / discover / set_enabled / reload
- 热加载:把 study_tool.pugins.<名字> 的模块从 sys.modules 摘掉后重新 import;
  重载前先 registry.unregister 旧 id,平台 id 改了也不会留残影
- 启用清单存设置项 plugins({"disabled": [...]}):未列出的默认启用,新插件自动生效;
  停用即从注册表摘掉,/api/platforms 与账号平台下拉同步消失
- platforms/registry.py 新增 unregister(pid)
- 新接口:GET /api/plugins、POST /api/plugins/{name}/enable|disable、
  POST /api/plugins/reload、POST /api/plugins/{name}/reload;失败返回 ok=false
- 同步 docs/pugin.md(新增「加载与启停」)、docs/architecture.md、docs/OPTIMIZATION.md、AGENTS.md
This commit is contained in:
study-tool
2026-10-31 15:50:06 +08:00
parent f1a57853f4
commit f8458cff51
7 changed files with 219 additions and 31 deletions
+5
View File
@@ -9,6 +9,11 @@ def register(platform: Platform) -> Platform:
return platform
def unregister(pid):
"""停用插件 / 重载前把实现从注册表摘掉;不在表里返回 None。"""
return _PLATFORMS.pop(pid or "", None)
def get(pid):
"""按 course["platform"] 取平台实现。"""
return _PLATFORMS.get(pid or "")
+117 -10
View File
@@ -3,24 +3,131 @@
pugin.json 注册信息:id / title / name(只写元数据,不写任何能力绑定)
main.py `Platform` 子类,import 时用 `registry.register(...)` 注册自己
加载 = 扫目录逐个 import `main.py`。插件里不用声明「哪个函数绑定哪个槽位」,
子类实现了哪些方法就具备哪些能力(映射见 `platforms/contract.py`)。
要换加载方式,只改这里的 `load_plugins()`。
对外四个函数(路由 `/api/plugins*` 直接用):
load_plugins() 启动时按启用清单加载全部插件
discover() 磁盘清单 + 启用 / 已加载状态(不 import 插件代码)
set_enabled(name, bool) 启用 / 停用,立即生效并落库
reload(name=None) 重新 import(改完插件代码不用重启服务)
启用清单存在设置项 `plugins` 里(`{"disabled": [...]}`):没列进去的默认启用,
新丢进目录的插件会自动生效;停用的插件从注册表摘掉,`/api/platforms` 里不再出现。
"""
import importlib
import json
import sys
from pathlib import Path
from study_tool import runtime, store
from study_tool.platforms import registry
HERE = Path(__file__).resolve().parent
SETTING = "plugins"
_LOADED = {} # 插件名 -> 注册时用的平台 id,停用 / 重载时据此摘下旧实现
def _meta(name):
meta = HERE / name / "pugin.json"
if not meta.is_file():
raise RuntimeError("没有这个插件:%s" % name)
return json.loads(meta.read_text(encoding="utf-8"))
def _disabled():
return set((store.get_setting(SETTING) or {}).get("disabled") or [])
def _save_disabled(names):
store.set_setting(SETTING, {"disabled": sorted(names)})
def _forget(name):
"""把该插件的模块(含子模块)从 sys.modules 摘掉,下次 import 会重新执行。"""
pkg = "%s.%s" % (__name__, name)
for mod in [m for m in sys.modules if m == pkg or m.startswith(pkg + ".")]:
del sys.modules[mod]
def _load(name):
"""import 一个插件并确认它注册成功;重复调用就是重新加载。"""
pid = _meta(name).get("id") or ""
_unload(name) # 旧实现先下线,id 改了也不会留下残影
importlib.import_module("%s.%s.main" % (__name__, name))
platform = registry.get(pid)
if platform is None:
raise RuntimeError("插件 %s 没有注册 id=%r" % (name, pid))
_LOADED[name] = pid
return platform
def _unload(name):
pid = _LOADED.pop(name, None)
if pid:
registry.unregister(pid)
_forget(name)
def discover():
"""磁盘上的插件清单;不 import 插件代码。"""
disabled = _disabled()
out = []
for meta in sorted(HERE.glob("*/pugin.json")):
name = meta.parent.name
info = json.loads(meta.read_text(encoding="utf-8"))
platform = registry.get(info.get("id") or "")
out.append({
"name": name,
"id": info.get("id") or "",
"title": info.get("title") or "",
"enabled": name not in disabled,
"loaded": platform is not None,
"label": platform.label if platform else "",
"capabilities": platform.capabilities() if platform else [],
})
return out
def load_plugins():
"""导入 `pugins/` 下的全部插件(导入即注册)。幂等,app 启动时调用一次。"""
for meta in sorted(HERE.glob("*/pugin.json")):
info = json.loads(meta.read_text(encoding="utf-8"))
name = meta.parent.name
importlib.import_module("%s.%s.main" % (__name__, name))
if not registry.get(info.get("id") or ""):
raise RuntimeError("插件 %s 没有注册 id=%r" % (name, info.get("id")))
"""启动时加载启用清单里的全部插件。幂等。"""
for item in discover():
if not item["enabled"]:
continue
try:
_load(item["name"])
except Exception as e:
runtime.log("system", "插件 %s 加载失败:%s" % (item["name"], e), level="warn")
def set_enabled(name, enabled):
"""启用 / 停用插件并立即生效;返回最新的插件清单。"""
if enabled:
platform = _load(name) # 先加载,失败了不写进启用清单
disabled = _disabled()
disabled.discard(name)
_save_disabled(disabled)
runtime.log("system", "插件已启用:%s(%s)" % (name, platform.label))
else:
disabled = _disabled()
disabled.add(name)
_save_disabled(disabled)
_unload(name)
runtime.log("system", "插件已停用:%s" % name)
return discover()
def reload(name=None):
"""重新 import 插件;name 为空 = 重载所有已启用的插件。"""
items = discover()
if name:
item = next((i for i in items if i["name"] == name), None)
if item is None:
raise RuntimeError("没有这个插件:%s" % name)
if not item["enabled"]:
raise RuntimeError("插件 %s 已停用,先启用再重载" % name)
names = [name]
else:
names = [i["name"] for i in items if i["enabled"]]
for n in names:
_load(n)
runtime.log("system", "插件已重载:%s" % n)
return discover()
+40 -2
View File
@@ -1,11 +1,11 @@
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。"""
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志 / 插件管理接口。"""
import threading
import time
from fastapi.responses import HTMLResponse, JSONResponse
from study_tool.webapp import app
from study_tool import store
from study_tool import pugins, store
from study_tool.capture import *
from study_tool.config import *
from study_tool.engine import study_worker
@@ -25,6 +25,44 @@ def api_platforms():
for p in registry.all()]}
# ------------------------------------------------------------------ 插件管理
# 插件目录 pugins/<名字>/,实现见 study_tool.pugins。
@app.get("/api/plugins")
def api_plugins():
"""磁盘上的插件清单:是否启用、是否已加载、加载后的 label 与 capabilities。"""
return {"ok": True, "plugins": pugins.discover()}
@app.post("/api/plugins/{name}/enable")
def api_plugin_enable(name: str):
return _plugin_call(lambda: pugins.set_enabled(name, True))
@app.post("/api/plugins/{name}/disable")
def api_plugin_disable(name: str):
return _plugin_call(lambda: pugins.set_enabled(name, False))
@app.post("/api/plugins/reload")
def api_plugins_reload():
"""重载全部已启用插件:改完插件代码不用重启服务。"""
return _plugin_call(pugins.reload)
@app.post("/api/plugins/{name}/reload")
def api_plugin_reload(name: str):
return _plugin_call(lambda: pugins.reload(name))
def _plugin_call(fn):
"""插件操作失败不抛 500,返回 ok=false 给前端提示。"""
try:
return {"ok": True, "plugins": fn()}
except Exception as e:
return {"ok": False, "error": str(e), "plugins": pugins.discover()}
# ------------------------------------------------------------------ 运行日志 / 快照
@app.get("/api/log/history")