feat: Add plugins — catalogue on/off à effet réel (v7.58.0, phase 8/8)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h7m52s

- 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:
2026-10-07 10:19:25 -04:00
parent f8acb906e5
commit 16f73fe39e
20 changed files with 495 additions and 35 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog - FlowDeck # 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) ## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8)
### Added ### Added
+26 -9
View File
@@ -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) > **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
> un menu à sections, tel que demandé : > 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é, - [x] **Livraison** — `VERSION` + `app/main.py` = **7.57.0**, OpenAPI régénéré,
CHANGELOG, ce ROADMAP 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é - [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) —
- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin migration **35**, table pré-remplie avec les **3 modules** ; ligne absente =
désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de activé (défaut sûr)
l'agent, web clipper, automations) - [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante
- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu) (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 *Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le
2026-10-06 (v7.51.0 → v7.57.0)**, phase 8 (plugins) à valider avant « go ». 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.* Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
--- ---
+1 -1
View File
@@ -1 +1 @@
7.57.0 7.58.0
+11 -5
View File
@@ -5,7 +5,7 @@ import asyncio
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as _StarHTTPException 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.webauthn import router as webauthn_router
from app.routers.wiki import router as wiki_router from app.routers.wiki import router as wiki_router
from app.routers.workers import router as workers_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 from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig( logging.basicConfig(
@@ -185,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.57.0", version="7.58.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
@@ -235,7 +236,9 @@ app.include_router(sharing.router)
app.include_router(sidebar_config.router) app.include_router(sidebar_config.router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(notifications_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(collaboration_router)
app.include_router(emoji_router) app.include_router(emoji_router)
app.include_router(realtime_router) app.include_router(realtime_router)
@@ -247,8 +250,11 @@ app.include_router(sync.router)
app.include_router(imports_router) app.include_router(imports_router)
app.include_router(import_page_router) app.include_router(import_page_router)
app.include_router(permissions_router) app.include_router(permissions_router)
app.include_router(web_clipper_api_router) # Plugin « web-clipper » OFF → page /extensions + API clip refusées
app.include_router(web_clipper_router) 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_router)
app.include_router(api_v2_agent_router) app.include_router(api_v2_agent_router)
app.include_router(sites_router) app.include_router(sites_router)
+24
View File
@@ -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)") @register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
def _migration_connector_kinds(conn: sqlite3.Connection) -> None: def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et """Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
+18 -1
View File
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from app.auth.session import get_current_user from app.auth.session import get_current_user
from app.config import settings from app.config import settings
from app.db import get_conn 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.agent_engine import AgentEngine, undo_action
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import ( from app.services.llm_config import (
@@ -647,6 +647,23 @@ def list_connectors_route(request: Request):
return {"connectors": connectors.list_connectors(user_id)} 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") @router.post("/connectors")
def create_connectors_route(request: Request, body: dict = Body(default={})): def create_connectors_route(request: Request, body: dict = Body(default={})):
"""Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF).""" """Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF)."""
+4 -1
View File
@@ -27,7 +27,7 @@ import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from app.db import get_conn 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 from app.services.http_client import shared_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -479,6 +479,9 @@ async def automation_scheduler():
"""Background loop: fire due cron automations (checked every 60s).""" """Background loop: fire due cron automations (checked every 60s)."""
while True: while True:
try: try:
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
await asyncio.sleep(60)
continue
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1" "SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
+77
View File
@@ -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
+7 -1
View File
@@ -1172,10 +1172,16 @@ class ToolRegistry:
tools = dict(self.tools) tools = dict(self.tools)
try: try:
from app.services import mcp_client from app.services import mcp_client
from app.services import plugins as plugins_service
for entry in mcp_client.cached_tools(): for entry in mcp_client.cached_tools():
tools[entry["name"]] = McpTool(entry) 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 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 return tools
def list(self, scope: dict | None = None) -> list[str]: def list(self, scope: dict | None = None) -> list[str]:
+4 -4
View File
@@ -138,13 +138,13 @@
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div> <div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div> <div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div> <div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div> {% if plugin_enabled('web-clipper') %}<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>{% endif %}
<div class="settings-nav-header">Workspace</div> <div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div> <div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div> <div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div> <div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div> <div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div> {% if plugin_enabled('automations') %}<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>{% endif %}
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div> <div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins --> <!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin"> <template x-if="userIsAdmin">
@@ -423,7 +423,7 @@
</div> </div>
<!-- Extensions (Web Clipper) --> <!-- Extensions (Web Clipper) -->
<div x-show="activeSection==='extensions'"> <div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} &amp;&amp; activeSection==='extensions'">
<h2>Extensions</h2> <h2>Extensions</h2>
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p> <p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
<div class="setting-group"> <div class="setting-group">
@@ -584,7 +584,7 @@
</div> </div>
<!-- Automations --> <!-- Automations -->
<div x-show="activeSection==='automations'"> <div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} &amp;&amp; activeSection==='automations'">
<h2>Automations</h2> <h2>Automations</h2>
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p> <p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
+8
View File
@@ -77,3 +77,11 @@ except OSError: # pragma: no cover
ENV.globals["asset_version"] = ASSET_VERSION ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get() ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.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)
+22
View File
@@ -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. `initialize` / `tools/list` / `tools/call` des serveurs streamables courants.
Upgrade : client SSE persistant si un serveur ne répond qu'en flux. 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 ## 10. Hors périmètre
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ. - Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
+73 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.57.0" "version": "7.58.0"
}, },
"paths": { "paths": {
"/auth/register": { "/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}": { "/api/agent/connectors/{connector_id}": {
"patch": { "patch": {
"tags": [ "tags": [
+41 -2
View File
@@ -44,7 +44,7 @@
{key:'canvases', icon:'🎨', label:'Design System – Canevas', {key:'canvases', icon:'🎨', label:'Design System – Canevas',
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true}, sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
{key:'plugins', icon:'🧩', label:'Add plugins', {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)', {key:'memory', icon:'🧠', label:'Mémoire (on/off)',
sub:'Résumés des échanges injectés à chaque run', action:'memory'} sub:'Résumés des échanges injectés à chaque run', action:'memory'}
]; ];
@@ -74,7 +74,7 @@
skillForm: {id: null, name: '', description: '', prompt_template: ''}, skillForm: {id: null, name: '', description: '', prompt_template: ''},
galleryFilter: '', galleryFilter: '',
canvases: [], canvas: null, canvases: [], canvas: null,
connectors: [], connector: null, connectors: [], connector: null, plugins: [],
connectorForm: {name: '', url: '', secret: '', kind: 'custom', connectorForm: {name: '', url: '', secret: '', kind: 'custom',
auth: 'bearer', hint: ''}, auth: 'bearer', hint: ''},
cmdFocus: -1, slashDismiss: false, cmdFocus: -1, slashDismiss: false,
@@ -1148,6 +1148,7 @@
if(s === 'connector-new') return 'Ajouter un connecteur'; if(s === 'connector-new') return 'Ajouter un connecteur';
if(s === 'canvases') return 'Design System – Canevas'; if(s === 'canvases') return 'Design System – Canevas';
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas'; if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
if(s === 'plugins') return 'Add plugins';
return 'Ajouter au contexte'; return 'Ajouter au contexte';
}, },
get plusItems(){ get plusItems(){
@@ -1163,6 +1164,7 @@
if(s === 'connector-new') return []; if(s === 'connector-new') return [];
if(s === 'canvases') return this._canvasItems(); if(s === 'canvases') return this._canvasItems();
if(s === 'canvas') return this._canvasActionItems(); if(s === 'canvas') return this._canvasActionItems();
if(s === 'plugins') return this._pluginItems();
if(s === 'root'){ if(s === 'root'){
// l'entrée Mémoire reflète l'état réel du toggle de la conversation // l'entrée Mémoire reflète l'état réel du toggle de la conversation
var on = this.memoryOn; var on = this.memoryOn;
@@ -1274,6 +1276,39 @@
var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : ''; var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : '';
return mark + (c.detail || c.status) + tools + (c.enabled ? '' : ' · désactivé'); 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(){ _connectorItems(){
var self = this; var self = this;
var out = (this.connectors || []).map(function(c){ var out = (this.connectors || []).map(function(c){
@@ -1579,6 +1614,10 @@
auth:'bearer', hint:''}; auth:'bearer', hint:''};
this.plusSection = 'connector-new'; this.plusFocus = -1; return; 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-probe'){ this.connectorProbe(); return; }
if(it.action === 'connector-toggle'){ this.connectorToggle(); return; } if(it.action === 'connector-toggle'){ this.connectorToggle(); return; }
if(it.action === 'connector-delete'){ this.connectorDelete(); return; } if(it.action === 'connector-delete'){ this.connectorDelete(); return; }
+2 -2
View File
@@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states():
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"): for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
assert f"key:{key}" in root, key assert f"key:{key}" in root, key
assert "action:'files'" in root and "action:'skills'" in root assert "action:'files'" in root and "action:'skills'" in root
# phases 1-5 livrées → il reste 1 section « bientôt » (plugins) # v7.58.0 : menu complet — plus aucune section « bientôt » (plugins vivant)
assert root.count("disabled:true") == 1 assert root.count("disabled:true") == 0
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0] files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
assert "action:'search'" in files and "action:'browse'" in files assert "action:'search'" in files and "action:'browse'" in files
+1 -2
View File
@@ -64,8 +64,7 @@ def test_canvases_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8") src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'canvases'" in root assert "action:'canvases'" in root
# phases 1-5 livrées → 1 section « bientôt » restante (plugins) assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
assert root.count("disabled:true") == 1
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"): for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
assert action in src, action assert action in src, action
# piège /use : la clé builtin part DANS LE BODY, pas en query string # piège /use : la clé builtin part DANS LE BODY, pas en query string
+1 -2
View File
@@ -129,8 +129,7 @@ def test_memory_menu_toggle_wired():
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'memory'" in root assert "action:'memory'" in root
assert "bientôt (phase 4)" not 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") == 0 # v7.58.0 : menu complet
assert root.count("disabled:true") == 1
assert "toggleMemory()" in src assert "toggleMemory()" in src
assert "memory_enabled: next" in src assert "memory_enabled: next" in src
engine = ENGINE.read_text(encoding="utf-8") engine = ENGINE.read_text(encoding="utf-8")
+1 -2
View File
@@ -158,8 +158,7 @@ def test_connectors_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8") src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'connectors'" in root assert "action:'connectors'" in root
# phases 1-5 livrées → seule la section Plugins reste « bientôt » assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
assert root.count("disabled:true") == 1
for action in ("'connector'", "'connector-new'", "'connector-probe'", for action in ("'connector'", "'connector-new'", "'connector-probe'",
"'connector-toggle'", "'connector-delete'"): "'connector-toggle'", "'connector-delete'"):
assert action in src, action assert action in src, action
+2 -2
View File
@@ -194,9 +194,9 @@ def test_connectors_menu_wired_for_presets(client):
for token in ('value="discord"', 'value="telegram"', 'value="mcp"', for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
'connectorForm.kind'): 'connectorForm.kind'):
assert token in html, token 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] 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") resp = client.get("/accounts")
assert resp.status_code == 200 and "connectorPreset" in resp.text assert resp.status_code == 200 and "connectorPreset" in resp.text
+142
View File
@@ -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 &amp;&amp; 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