feat: Add plugins — catalogue on/off à effet réel (v7.58.0, phase 8/8)
- app/services/plugins.py + migration 35 : table plugins (slug, name,
description, enabled) pré-remplie avec 3 modules câblés — web-tools,
web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
(aucun router touché) → toutes les routes /workspace/automations* refusées +
garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
« Extensions » / « Automations » en {% if %} (absentes du DOM), sections
conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
(slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
/api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
This commit is contained in:
@@ -27,7 +27,7 @@ import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
from app.services import notifications, plugins
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -479,6 +479,9 @@ async def automation_scheduler():
|
||||
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||
while True:
|
||||
try:
|
||||
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Plugins de l'instance — on/off à **effet réel** (v7.58.0).
|
||||
|
||||
Chaque plugin câble une chose concrète, jamais un simple drapeau :
|
||||
|
||||
- ``web-tools`` → retiré du registre d'outils de l'agent (schéma + exécution)
|
||||
- ``web-clipper`` → routers ``/extensions`` et ``/api/v2/web-clipper/*`` refusés
|
||||
- ``automations`` → routes ``/workspace/automations*`` refusées + scheduler
|
||||
|
||||
Le garde de route est une **dépendance FastAPI posée à l'`include_router`**
|
||||
(``main.py``) : 3 lignes, aucun fichier de router touché.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
# slug → (nom affiché, description affichée)
|
||||
CATALOG: list[tuple[str, str, str]] = [
|
||||
("web-tools", "Outils web de l'agent",
|
||||
"Retire web_search et fetch_url du registre d'outils de l'agent"),
|
||||
("web-clipper", "Web Clipper",
|
||||
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
|
||||
("automations", "Automations",
|
||||
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
|
||||
]
|
||||
|
||||
# slug → outils LLM retirés du registre quand le plugin est OFF
|
||||
PLUGIN_TOOLS: dict[str, tuple[str, ...]] = {"web-tools": ("web_search", "fetch_url")}
|
||||
|
||||
|
||||
def is_enabled(slug: str) -> bool:
|
||||
"""Ligne absente = activé (défaut sûr : ne rien couper par accident)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT enabled FROM plugins WHERE slug=?", (slug,)).fetchone()
|
||||
return True if row is None else bool(row["enabled"])
|
||||
|
||||
|
||||
def list_plugins() -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = {r["slug"]: bool(r["enabled"])
|
||||
for r in conn.execute("SELECT slug, enabled FROM plugins")}
|
||||
return [{"slug": slug, "name": name, "description": desc,
|
||||
"enabled": rows.get(slug, True)}
|
||||
for slug, name, desc in CATALOG]
|
||||
|
||||
|
||||
def set_enabled(slug: str, enabled: bool) -> dict:
|
||||
if not any(s == slug for s, _, _ in CATALOG):
|
||||
raise ValueError(f"Plugin inconnu: {slug}")
|
||||
name = next(n for s, n, _ in CATALOG if s == slug)
|
||||
desc = next(d for s, _, d in CATALOG if s == slug)
|
||||
with get_conn() as conn:
|
||||
# SQLite vérifie NOT NULL AVANT l'upsert : il faut fournir name/description.
|
||||
conn.execute(
|
||||
"INSERT INTO plugins (slug, name, description, enabled) VALUES (?,?,?,?) "
|
||||
"ON CONFLICT(slug) DO UPDATE SET enabled=excluded.enabled",
|
||||
(slug, name, desc, 1 if enabled else 0))
|
||||
conn.commit()
|
||||
return next(p for p in list_plugins() if p["slug"] == slug)
|
||||
|
||||
|
||||
def plugin_required(slug: str):
|
||||
"""Dépendance FastAPI : 404 dès que le plugin est désactivé.
|
||||
|
||||
`Request` doit être importé au **module** : les annotations sont des
|
||||
chaînes (`from __future__ import annotations`) et FastAPI les résout dans
|
||||
les globales du module — sinon il lit un query param → 422.
|
||||
"""
|
||||
|
||||
def dep(request: Request) -> None:
|
||||
# annotation Request OBLIGATOIRE : sans elle FastAPI lit un query param → 422
|
||||
if not is_enabled(slug):
|
||||
raise HTTPException(status_code=404, detail=f"Plugin désactivé: {slug}")
|
||||
|
||||
return dep
|
||||
@@ -1172,10 +1172,16 @@ class ToolRegistry:
|
||||
tools = dict(self.tools)
|
||||
try:
|
||||
from app.services import mcp_client
|
||||
from app.services import plugins as plugins_service
|
||||
for entry in mcp_client.cached_tools():
|
||||
tools[entry["name"]] = McpTool(entry)
|
||||
# plugin « web-tools » OFF → outils web retirés du schéma ET de execute()
|
||||
for slug, names in plugins_service.PLUGIN_TOOLS.items():
|
||||
if not plugins_service.is_enabled(slug):
|
||||
for name in names:
|
||||
tools.pop(name, None)
|
||||
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
|
||||
logger.exception("MCP dynamic tools load failed")
|
||||
logger.exception("dynamic tools load failed")
|
||||
return tools
|
||||
|
||||
def list(self, scope: dict | None = None) -> list[str]:
|
||||
|
||||
Reference in New Issue
Block a user