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:
@@ -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
@@ -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.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+11
-5
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)."""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
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]:
|
||||||
|
|||||||
@@ -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 & IA</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & 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' }} && 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' }} && 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user