diff --git a/CHANGELOG.md b/CHANGELOG.md index c371d2c..c7abea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog - FlowDeck +## v7.58.0 (2026-10-06) — Add plugins : catalogue on/off à effet réel (phase 8/8) + +### Added + +- **`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 : rien n'est coupé par accident). +- **Effet réel, jamais un simple drapeau** : + - `automations` OFF → dépendance FastAPI posée **à l'`include_router`** + (3 lignes dans `main.py`, aucun router touché) → toutes les routes + `/workspace/automations*` refusées, **et** le scheduler de fond passe en + veille (garde sur chaque tick) ; + - `web-clipper` OFF → `GET /extensions` + tout `/api/v2/web-clipper/*` + refusés ; + - `web-tools` OFF → `web_search` et `fetch_url` retirés du **schéma** de + l'agent et de `execute()` (`ToolRegistry._all()`) : le LLM ne les voit plus. +- **UI masquée côté serveur** — nouveau global Jinja `plugin_enabled(slug)` : + `settings.html` rend la nav « Extensions » / « Automations » sous + `{% if %}` (élément **absent** du DOM, pas seulement caché) et conditionne + les sections en `x-show`. +- **Menu +** — l'entrée « Add plugins » devient une vraie section : liste des + 3 plugins (état + description) avec bascule ; + `GET /api/agent/plugins` + `PATCH /api/agent/plugins/{slug}` (slug inconnu → + 404, 401 sans session). +- **Tests** — `tests/test_v758_plugins.py` : **10 tests** — catalogue, + persistance en base, routes refusées (302 hors `/api` + 404 JSON pour + `/api*`, handler unifié de `main.py`), outils retirés du registre, nav + disparue du `/settings` rendu, câblage du menu, 401. + ## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8) ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 4bd3370..c2e5cde 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB --- -## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-7 ✅ 2026-10-06 · phase 8 planifiée) +## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-8 ✅ 2026-10-06 — menu + complet) > **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition) > un menu à sections, tel que demandé : @@ -496,18 +496,35 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. - [x] **Livraison** — `VERSION` + `app/main.py` = **7.57.0**, OpenAPI régénéré, CHANGELOG, ce ROADMAP -### Phase 8 — v7.58.0 — Add plugins · effort M +### Phase 8 — v7.58.0 — Add plugins · effort M ✅ -- [ ] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — état persisté -- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin - désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de - l'agent, web clipper, automations) -- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu) +- [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — + migration **35**, table pré-remplie avec les **3 modules** ; ligne absente = + activé (défaut sûr) +- [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante + (fini le `disabled:true`) : liste des 3 plugins + bascule via + `GET/PATCH /api/agent/plugins[/slug]` +- [x] **Bascule on/off à effet réel** : + - `automations` → **toutes** les routes `/workspace/automations*` refusées + (dépendance FastAPI posée **à l'`include_router`**, aucun router touché) + + scheduler de fond en veille (garde par tick) ; + - `web-clipper` → `GET /extensions` + `/api/v2/web-clipper/*` refusés ; + - `web-tools` → `web_search` / `fetch_url` retirés du **schéma** et de + `execute()` (`ToolRegistry._all()`) ; + - UI → nav « Extensions » / « Automations » rendues sous + `{% if plugin_enabled(...) %}` (global Jinja, élément **absent** du DOM), + sections conditionnées en `x-show` +- [x] **Tests** — `tests/test_v758_plugins.py` : **10 tests** — plugin OFF = + route refusée (302 hors `/api`, 404 JSON pour `/api*`) + UI absente + outil + retiré, persistance, 401 sans session +- [x] **Livraison** — `VERSION` + `app/main.py` = **7.58.0**, OpenAPI régénéré + (525 chemins), CHANGELOG, ce ROADMAP --- -*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 7 livrées le -2026-10-06 (v7.51.0 → v7.57.0)**, phase 8 (plugins) à valider avant « go ». +*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le +2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet** (aucune section +« bientôt »). Toute extension ultérieure = nouvelle phase à valider. Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.* --- diff --git a/VERSION b/VERSION index f266389..70e2b22 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.57.0 +7.58.0 diff --git a/app/main.py b/app/main.py index dab6f38..6dde682 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ import asyncio import logging from contextlib import asynccontextmanager -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as _StarHTTPException @@ -62,6 +62,7 @@ from app.routers.web_clipper import router as web_clipper_router from app.routers.webauthn import router as webauthn_router from app.routers.wiki import router as wiki_router from app.routers.workers import router as workers_router +from app.services import plugins as plugins_service from app.services.webhook_outbound import init_webhook_tables logging.basicConfig( @@ -185,7 +186,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.57.0", + version="7.58.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, @@ -235,7 +236,9 @@ app.include_router(sharing.router) app.include_router(sidebar_config.router) app.include_router(export.router) app.include_router(notifications_router) -app.include_router(automations_router) +# Plugin « automations » OFF → chaque route de ce router renvoie 404 +app.include_router(automations_router, + dependencies=[Depends(plugins_service.plugin_required("automations"))]) app.include_router(collaboration_router) app.include_router(emoji_router) app.include_router(realtime_router) @@ -247,8 +250,11 @@ app.include_router(sync.router) app.include_router(imports_router) app.include_router(import_page_router) app.include_router(permissions_router) -app.include_router(web_clipper_api_router) -app.include_router(web_clipper_router) +# Plugin « web-clipper » OFF → page /extensions + API clip refusées +app.include_router(web_clipper_api_router, + dependencies=[Depends(plugins_service.plugin_required("web-clipper"))]) +app.include_router(web_clipper_router, + dependencies=[Depends(plugins_service.plugin_required("web-clipper"))]) app.include_router(api_v2_router) app.include_router(api_v2_agent_router) app.include_router(sites_router) diff --git a/app/migrations.py b/app/migrations.py index 51ee3c1..6712fae 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -1586,6 +1586,30 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None: ) +@register(35, "v7.58.0: registre des plugins (on/off à effet réel)") +def _migration_plugins(conn: sqlite3.Connection) -> None: + """Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels).""" + conn.execute( + """CREATE TABLE IF NOT EXISTS plugins ( + slug TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1 + )""" + ) + conn.executemany( + "INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)", + [ + ("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"), + ], + ) + + @register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)") def _migration_connector_kinds(conn: sqlite3.Connection) -> None: """Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et diff --git a/app/routers/agent.py b/app/routers/agent.py index 6674af6..c75d791 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse from app.auth.session import get_current_user from app.config import settings from app.db import get_conn -from app.services import connectors, oauth_connectors, skill_gallery +from app.services import connectors, oauth_connectors, plugins, skill_gallery from app.services.agent_engine import AgentEngine, undo_action from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient from app.services.llm_config import ( @@ -647,6 +647,23 @@ def list_connectors_route(request: Request): return {"connectors": connectors.list_connectors(user_id)} +@router.get("/plugins") +def list_plugins_route(request: Request): + """Catalogue des plugins de l'instance (menu + → « Add plugins »).""" + _current_user_id(request) + return {"plugins": plugins.list_plugins()} + + +@router.patch("/plugins/{slug}") +def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})): + """Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau.""" + _current_user_id(request) + try: + return plugins.set_enabled(slug, bool(body.get("enabled"))) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.post("/connectors") def create_connectors_route(request: Request, body: dict = Body(default={})): """Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF).""" diff --git a/app/services/automations.py b/app/services/automations.py index a5f77fd..cf090fc 100644 --- a/app/services/automations.py +++ b/app/services/automations.py @@ -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" diff --git a/app/services/plugins.py b/app/services/plugins.py new file mode 100644 index 0000000..3728cc1 --- /dev/null +++ b/app/services/plugins.py @@ -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 diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index c5a10c6..dcab6b2 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -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]: diff --git a/app/templates/settings.html b/app/templates/settings.html index 4c75f37..c8bf7fc 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -138,13 +138,13 @@
{{ fd_icon("bell",14) }} Notifications
{{ fd_icon("key",14) }} API tokens
{{ fd_icon("users",14) }} Sessions
-
{{ fd_icon("zap",14) }} Extensions
+ {% if plugin_enabled('web-clipper') %}
{{ fd_icon("zap",14) }} Extensions
{% endif %}
Workspace
{{ fd_icon("file",14) }} General
{{ fd_icon("tag",14) }} Tags
Features
{{ fd_icon("link",14) }} Integrations
-
{{ fd_icon("zap",14) }} Automations
+ {% if plugin_enabled('automations') %}
{{ fd_icon("zap",14) }} Automations
{% endif %}
{{ fd_icon("bot",14) }} Agent & IA