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 @@
FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.
Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.
diff --git a/app/templating.py b/app/templating.py index 27fd934..55f3a5a 100644 --- a/app/templating.py +++ b/app/templating.py @@ -77,3 +77,11 @@ except OSError: # pragma: no cover ENV.globals["asset_version"] = ASSET_VERSION ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get() ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get() +# Plugins activés : conditionne les blocs de UI rendus côté serveur. +ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug) + + +def _plugin_enabled(slug: str) -> bool: + from app.services.plugins import is_enabled + + return is_enabled(slug) diff --git a/docs/V74_Agent_Plus_Menu.md b/docs/V74_Agent_Plus_Menu.md index da603f9..42c68f7 100644 --- a/docs/V74_Agent_Plus_Menu.md +++ b/docs/V74_Agent_Plus_Menu.md @@ -223,6 +223,28 @@ Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, RO `initialize` / `tools/list` / `tools/call` des serveurs streamables courants. Upgrade : client SSE persistant si un serveur ne répond qu'en flux. +## 9ter. Avenant phase 8 (v7.58.0) — décisions livrées + +- **Garde de route = dépendance posée à l'`include_router`** (`main.py`), pas + de décorateur par route ni de middleware : 3 lignes, aucun router modifié. + ⚠️ l'annotation `request: Request` doit être importée **au module** : avec + `from __future__ import annotations` les annotations sont des chaînes et + FastAPI les résout dans les globales du module — sinon il lit un query param + → 422 (piège réel, corrigé en cours de phase). +- **Effet réel aux 4 niveaux** : routes (dépendance), arrière-plan (garde de + tick du scheduler), outils (`ToolRegistry._all()`), UI (rendu serveur). +- **UI : `{% if %}` pour la nav, `x-show` pour les sections** — la nav est + **absente du DOM** (test « UI absente » au sens propre) ; les grosses + sections ne sont pas restructurées, leur booléen est rendu au serveur. +- **Handler unifié des 404** (comportement historique conservé) : hors `/api` + une route refusée **redirige 302 → /workspaces**, `/api*` répond 404 JSON — + les tests assertent les deux formes au lieu de réécrire le handler. +- **SQLite** : `INSERT … ON CONFLICT` évalue NOT NULL **avant** l'upsert → + fournir toutes les colonnes NOT NULL, même pour un simple changement d'état. +- `ponytail:` plafond assumé — pas de classe-adapter par plugin ni de + « marketplace » : ajouter un plugin = 1 tuple dans `CATALOG` (+ 1 garde si + son effet n'est pas déjà couvert). + ## 10. Hors périmètre - Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ. diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 9658182..5313079 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.57.0" + "version": "7.58.0" }, "paths": { "/auth/register": { @@ -16299,6 +16299,78 @@ } } }, + "/api/agent/plugins": { + "get": { + "tags": [ + "agent" + ], + "summary": "List Plugins Route", + "description": "Catalogue des plugins de l'instance (menu + → « Add plugins »).", + "operationId": "list_plugins_route_api_agent_plugins_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/agent/plugins/{slug}": { + "patch": { + "tags": [ + "agent" + ], + "summary": "Set Plugin Route", + "description": "Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau.", + "operationId": "set_plugin_route_api_agent_plugins__slug__patch", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Slug" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/agent/connectors/{connector_id}": { "patch": { "tags": [ diff --git a/static/js/agent_panel_2.js b/static/js/agent_panel_2.js index bc2eb77..a67b78c 100644 --- a/static/js/agent_panel_2.js +++ b/static/js/agent_panel_2.js @@ -44,7 +44,7 @@ {key:'canvases', icon:'🎨', label:'Design System – Canevas', sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true}, {key:'plugins', icon:'🧩', label:'Add plugins', - sub:'Modules on/off — bientôt (phase 8)', disabled:true}, + sub:'Modules on/off — effet réel : routes, UI, outils', action:'plugins', chev:true}, {key:'memory', icon:'🧠', label:'Mémoire (on/off)', sub:'Résumés des échanges injectés à chaque run', action:'memory'} ]; @@ -74,7 +74,7 @@ skillForm: {id: null, name: '', description: '', prompt_template: ''}, galleryFilter: '', canvases: [], canvas: null, - connectors: [], connector: null, + connectors: [], connector: null, plugins: [], connectorForm: {name: '', url: '', secret: '', kind: 'custom', auth: 'bearer', hint: ''}, cmdFocus: -1, slashDismiss: false, @@ -1148,6 +1148,7 @@ if(s === 'connector-new') return 'Ajouter un connecteur'; if(s === 'canvases') return 'Design System – Canevas'; if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas'; + if(s === 'plugins') return 'Add plugins'; return 'Ajouter au contexte'; }, get plusItems(){ @@ -1163,6 +1164,7 @@ if(s === 'connector-new') return []; if(s === 'canvases') return this._canvasItems(); if(s === 'canvas') return this._canvasActionItems(); + if(s === 'plugins') return this._pluginItems(); if(s === 'root'){ // l'entrée Mémoire reflète l'état réel du toggle de la conversation var on = this.memoryOn; @@ -1274,6 +1276,39 @@ var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : ''; return mark + (c.detail || c.status) + tools + (c.enabled ? '' : ' · désactivé'); }, + // ── Plugins de l'instance (v7.58.0) ── + fetchPlugins(){ + var self = this; + fetch('/api/agent/plugins').then(function(r){ return r.json(); }) + .then(function(d){ self.plugins = (d && d.plugins) || []; }) + .catch(function(){ self.plugins = []; }); + }, + _pluginItems(){ + var out = (this.plugins || []).map(function(pl){ + return {key:'pl-' + pl.slug, icon: pl.enabled ? '✅' : '⛔', + label: pl.name, + sub: (pl.enabled ? 'Activé' : 'Désactivé') + ' — ' + pl.description, + action:'plugin-toggle', slug: pl.slug}; + }); + if(!out.length) out.push({key:'pl-none', _sep:'Aucun plugin'}); + out.push({key:'pl-hint', _sep: + 'OFF = routes refusées, UI masquée, outils retirés'}); + return out; + }, + pluginToggle(slug){ + var self = this; + var pl = (this.plugins || []).filter(function(x){ return x.slug === slug; })[0]; + if(!pl) return; + fetch('/api/agent/plugins/' + encodeURIComponent(slug), { + method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, + body: JSON.stringify({enabled: !pl.enabled}) + }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if(!res.ok){ self.toast((res.d && res.d.detail) || 'Bascule impossible.', true); return; } + self.toast(res.d.enabled ? 'Plugin activé.' : 'Plugin désactivé.'); + self.fetchPlugins(); + }).catch(function(){ self.toast('Bascule impossible (réseau).', true); }); + }, _connectorItems(){ var self = this; var out = (this.connectors || []).map(function(c){ @@ -1579,6 +1614,10 @@ auth:'bearer', hint:''}; this.plusSection = 'connector-new'; this.plusFocus = -1; return; } + if(it.action === 'plugins'){ + this.fetchPlugins(); this.plusSection = 'plugins'; this.plusFocus = 0; return; + } + if(it.action === 'plugin-toggle'){ this.pluginToggle(it.slug); return; } if(it.action === 'connector-probe'){ this.connectorProbe(); return; } if(it.action === 'connector-toggle'){ this.connectorToggle(); return; } if(it.action === 'connector-delete'){ this.connectorDelete(); return; } diff --git a/tests/test_v751_plus_menu.py b/tests/test_v751_plus_menu.py index de6ba45..1b008f2 100644 --- a/tests/test_v751_plus_menu.py +++ b/tests/test_v751_plus_menu.py @@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states(): for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"): assert f"key:{key}" in root, key assert "action:'files'" in root and "action:'skills'" in root - # phases 1-5 livrées → il reste 1 section « bientôt » (plugins) - assert root.count("disabled:true") == 1 + # v7.58.0 : menu complet — plus aucune section « bientôt » (plugins vivant) + assert root.count("disabled:true") == 0 files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0] assert "action:'search'" in files and "action:'browse'" in files diff --git a/tests/test_v753_canvases.py b/tests/test_v753_canvases.py index 973dba1..2ef750b 100644 --- a/tests/test_v753_canvases.py +++ b/tests/test_v753_canvases.py @@ -64,8 +64,7 @@ def test_canvases_menu_wired(client): src = JS_PATH.read_text(encoding="utf-8") root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] assert "action:'canvases'" in root - # phases 1-5 livrées → 1 section « bientôt » restante (plugins) - assert root.count("disabled:true") == 1 + assert root.count("disabled:true") == 0 # v7.58.0 : menu complet for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"): assert action in src, action # piège /use : la clé builtin part DANS LE BODY, pas en query string diff --git a/tests/test_v754_memory.py b/tests/test_v754_memory.py index 97e737b..1c69d49 100644 --- a/tests/test_v754_memory.py +++ b/tests/test_v754_memory.py @@ -129,8 +129,7 @@ def test_memory_menu_toggle_wired(): root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] assert "action:'memory'" in root assert "bientôt (phase 4)" not in root - # phases 1-5 livrées → seul Plugins reste désactivé - assert root.count("disabled:true") == 1 + assert root.count("disabled:true") == 0 # v7.58.0 : menu complet assert "toggleMemory()" in src assert "memory_enabled: next" in src engine = ENGINE.read_text(encoding="utf-8") diff --git a/tests/test_v755_connectors.py b/tests/test_v755_connectors.py index f31b48c..5122793 100644 --- a/tests/test_v755_connectors.py +++ b/tests/test_v755_connectors.py @@ -158,8 +158,7 @@ def test_connectors_menu_wired(client): src = JS_PATH.read_text(encoding="utf-8") root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] assert "action:'connectors'" in root - # phases 1-5 livrées → seule la section Plugins reste « bientôt » - assert root.count("disabled:true") == 1 + assert root.count("disabled:true") == 0 # v7.58.0 : menu complet for action in ("'connector'", "'connector-new'", "'connector-probe'", "'connector-toggle'", "'connector-delete'"): assert action in src, action diff --git a/tests/test_v757_mcp_discord.py b/tests/test_v757_mcp_discord.py index dbf1714..a1b7e08 100644 --- a/tests/test_v757_mcp_discord.py +++ b/tests/test_v757_mcp_discord.py @@ -194,9 +194,9 @@ def test_connectors_menu_wired_for_presets(client): for token in ('value="discord"', 'value="telegram"', 'value="mcp"', 'connectorForm.kind'): assert token in html, token - # 1 seule section « bientôt » restante (plugins) + # v7.58.0 : plus aucune section « bientôt » (plugins rendu vivant) root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] - assert root.count("disabled:true") == 1 + assert root.count("disabled:true") == 0 resp = client.get("/accounts") assert resp.status_code == 200 and "connectorPreset" in resp.text diff --git a/tests/test_v758_plugins.py b/tests/test_v758_plugins.py new file mode 100644 index 0000000..ff3b070 --- /dev/null +++ b/tests/test_v758_plugins.py @@ -0,0 +1,142 @@ +"""v7.58.0 — Add plugins : registre persistant + on/off à effet réel.""" + +import asyncio +from pathlib import Path + +from conftest import anon_csrf + +from app.services import plugins +from app.services.tool_registry import ToolRegistry + +ROOT = Path(__file__).resolve().parent.parent +JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js" +PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html" +SETTINGS_HTML = ROOT / "app" / "templates" / "settings.html" +AUTOMATIONS_PY = ROOT / "app" / "services" / "automations.py" + + +def _set(client, slug: str, enabled: bool) -> dict: + r = client.patch(f"/api/agent/plugins/{slug}", json={"enabled": enabled}) + assert r.status_code == 200, r.text + return r.json() + + +# ── Catalogue ─────────────────────────────────────────────────────────────── + +def test_catalog_lists_the_three_wired_plugins(client): + data = client.get("/api/agent/plugins").json()["plugins"] + assert [p["slug"] for p in data] == ["web-tools", "web-clipper", "automations"] + assert all(p["enabled"] for p in data) + assert all(p["name"] and p["description"] for p in data) + + +def test_unknown_slug_is_404(client): + assert client.patch("/api/agent/plugins/nope", + json={"enabled": False}).status_code == 404 + + +def test_toggle_persists_in_db(client): + _set(client, "web-clipper", False) + assert plugins.is_enabled("web-clipper") is False # relu depuis la base + _set(client, "web-clipper", True) + assert plugins.is_enabled("web-clipper") is True + + +# ── Effet réel : routes refusées ──────────────────────────────────────────── + +def test_automations_routes_refused_when_plugin_off(client): + # 404 hors /api = redirection vers /workspaces (handler unifié de main.py) + def get(): + return client.get("/workspace/automations", follow_redirects=False) + + assert get().status_code == 200 + _set(client, "automations", False) + assert get().status_code == 302 + assert get().headers["location"] == "/workspaces" + assert client.post("/workspace/automations", json={}, + follow_redirects=False).status_code == 302 + assert plugins.is_enabled("automations") is False + _set(client, "automations", True) + assert get().status_code == 200 + + +def test_scheduler_tick_guarded_when_automations_off(client): + # le scheduler doit vérifier le plugin avant chaque tick ( effet réel, pas un + # drapeau lu nulle part ) + src = AUTOMATIONS_PY.read_text(encoding="utf-8") + assert 'plugins.is_enabled("automations")' in src + _set(client, "automations", False) + assert plugins.is_enabled("automations") is False + + +def test_web_clipper_api_and_page_refused_when_plugin_off(client): + def page(): + return client.get("/extensions", follow_redirects=False) + + assert client.get("/api/v2/web-clipper/status").status_code == 200 + assert page().status_code == 200 + _set(client, "web-clipper", False) + assert client.get("/api/v2/web-clipper/status").status_code == 404 + assert client.post("/api/v2/web-clipper/clip", json={}).status_code == 404 + assert page().status_code == 302 and page().headers["location"] == "/workspaces" + _set(client, "web-clipper", True) + assert client.get("/api/v2/web-clipper/status").status_code == 200 + assert page().status_code == 200 + + +# ── Effet réel : outils de l'agent ────────────────────────────────────────── + +def test_web_tools_leaves_the_llm_registry_when_plugin_off(client): + names = {t["name"] for t in ToolRegistry().schema()} + assert {"web_search", "fetch_url"} <= names + + _set(client, "web-tools", False) + names = {t["name"] for t in ToolRegistry().schema()} + assert not ({"web_search", "fetch_url"} & names) + res = asyncio.run(ToolRegistry().execute("fetch_url", + {"url": "https://example.com"})) + assert res.status == "error" and "inconnu" in res.message.lower() + + _set(client, "web-tools", True) + names = {t["name"] for t in ToolRegistry().schema()} + assert {"web_search", "fetch_url"} <= names + + +# ── UI : settings rendu côté serveur + menu + ────────────────────────────── + +def test_settings_hides_disabled_plugins(client): + html = client.get("/settings").text + assert "navTo('extensions')" in html and "navTo('automations')" in html # ON + + _set(client, "automations", False) + _set(client, "web-clipper", False) + html = client.get("/settings").text + assert "navTo('automations')" not in html # nav retirée + assert "navTo('extensions')" not in html + assert "x-show=\"false && activeSection==='automations'\"" in html + + _set(client, "automations", True) + _set(client, "web-clipper", True) + html = client.get("/settings").text + assert "navTo('automations')" in html and "navTo('extensions')" in html + + +def test_menu_catalogue_is_live(client): + src = JS_PATH.read_text(encoding="utf-8") + for token in ("action:'plugins'", "_pluginItems()", "pluginToggle(slug)", + "/api/agent/plugins", "Add plugins"): + assert token in src, token + # la section n'est plus une coquille « bientôt » + root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] + assert "disabled:true" not in root + panel = PANEL_HTML.read_text(encoding="utf-8") + assert "plusSection!=='root'" in panel # en-tête/rétour génériques + settings = SETTINGS_HTML.read_text(encoding="utf-8") + assert settings.count("plugin_enabled(") >= 4 + + +def test_plugins_routes_require_session(client): + anon_csrf(client) + assert client.get("/api/agent/plugins").status_code == 401 + assert client.patch("/api/agent/plugins/automations", + json={"enabled": False}).status_code == 401