Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
033490c464 | ||
|
|
f132885b08 | ||
|
|
a79bdc1641 | ||
|
|
a67bd252aa | ||
|
|
39d34ac866 | ||
|
|
efa3d57e93 | ||
|
|
992200a90a | ||
|
|
21b96afa12 | ||
|
|
16f73fe39e | ||
|
|
f8acb906e5 | ||
|
|
1d7750bda0 | ||
|
|
18c72d77fe | ||
|
|
64c85caffc | ||
|
|
a68e8848ea | ||
|
|
0f86294abc |
@@ -20,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET=
|
||||
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
||||
OAUTH_REDIRECT_URI=
|
||||
|
||||
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
|
||||
# Vidés = connecteur « non configuré » (le menu + affiche la raison).
|
||||
# Le redirect URI à enregistrer est dérivé d'APP_BASE_URL :
|
||||
# {APP_BASE_URL}/api/agent/connectors/oauth/google/callback
|
||||
# {APP_BASE_URL}/api/agent/connectors/oauth/ms365/callback
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
MS_CLIENT_ID=
|
||||
MS_CLIENT_SECRET=
|
||||
|
||||
# ── App ──
|
||||
APP_SECRET_KEY=change-me-to-random
|
||||
APP_HOST=0.0.0.0
|
||||
|
||||
+391
@@ -1,5 +1,396 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.61.0 (2026-10-08) — Menu contextuel de bloc refondu façon Notion
|
||||
|
||||
### Added
|
||||
|
||||
- **Panneau de gauche** : barre de recherche « Search actions... » (filtre en
|
||||
direct), titre de section « Text », puis les 10 actions dans l'ordre Notion :
|
||||
Turn into ›, Color ›, Copy link to block (`Alt+⇧+L`), Duplicate (`Ctrl+D`),
|
||||
Move to (`Ctrl+⇧+P`), Delete (`Del`), Comment (`Ctrl+⇧+M`), Suggest edits
|
||||
(`Ctrl+⇧+Alt+X`), Ask AI (`Ctrl+J`), Skills › — avec les raccourcis
|
||||
affichés, et le pied de page « Last edited by … / Today at H:MM / N words,
|
||||
M characters » (auteur lu dans le compte, heure = `updatedAt` du bloc page,
|
||||
taille calculée sur le contenu du bloc).
|
||||
- **Sous-menu « Turn into »** (survol du panneau de gauche → panneau de
|
||||
droite, fermeture différée) : Text ✓ (type actuel coché), Heading 1-4,
|
||||
Bulleted list, Numbered list, To-do list, Toggle list, Code, Quote,
|
||||
Callout, Block equation, Synced block, et **2/3/4/5 columns** avec
|
||||
info-bulle « Create N columns of blocks » ; `turnInto(idx, type, cols)`
|
||||
crée désormais N colonnes et ouvre le sélecteur de source pour les blocs
|
||||
synchronisés.
|
||||
- **Sous-menu « Color »** : sections **Last used** (raccourci `Ctrl+⇧+H`),
|
||||
**Text color** (Default, Gray, Brown, Orange, Yellow ✓, Green, Blue,
|
||||
Purple, Pink, Red — glyphe « A » à la couleur) et **Background color**
|
||||
(même palette avec carré de couleur) ; la couleur utilisée est cochée, la
|
||||
dernière est mémorisée (`localStorage fdLastBlockColor`).
|
||||
- **Sous-menu « Skills »** : liste réelle des compétences de l'espace
|
||||
(`GET /api/agent/skills`) → applique le `prompt_template` au contenu du
|
||||
bloc via l'IA et insère le résultat dessous.
|
||||
- **Raccourcis** — handler clavier dédié : `Ctrl+J` (Ask AI), `Ctrl+⇧+H`
|
||||
(dernière couleur), `Ctrl+⇧+M` (commenter le bloc : sélection du contenu +
|
||||
tiroir de commentaires), `Ctrl+⇧+Alt+X` (suggérer une édition → résultat
|
||||
inséré dessous), `Alt+⇧+L` (copier le lien), `Ctrl+⇧+P` (déplacer vers…),
|
||||
`Ctrl+D` (dupliquer — déjà géré par la saisie, pas de double), `Del`
|
||||
(**seulement avec le menu ouvert**, jamais pendant la saisie), `Échap`
|
||||
(fermer).
|
||||
- **Actions Suggest edits / Ask AI / Comment / Skills** réelles côté éditeur
|
||||
(`suggestEdit`, `askAI`, `commentBlock`, `runSkill`) — Ask AI ouvre le
|
||||
composeur inline sans effacer le contenu du bloc.
|
||||
- **Styles** : `.bm-group`, `.bm-foot`, `.bm-check`, `.bm-current`,
|
||||
`.bm-dot`, sous-menu `60vh` défilable, recherche focalisée à l'ouverture.
|
||||
- **Tests** — `tests/test_v761_block_menu.py` : **9 tests** (structure et
|
||||
ordre du panneau, dispatch des 10 actions, 3 sous-menus, survol/fermeture,
|
||||
les 9 raccourcis, comportement node : `turnInto(…, 'columns', 3)` → 3
|
||||
colonnes, styles) + `test_v7593` adapté à la nouvelle signature.
|
||||
|
||||
### Removed
|
||||
|
||||
- Entrées **Copy / Cut / Paste blocks** du menu (absentes de la spécification
|
||||
Notion) — les raccourcis `Ctrl+C/X/V` sur blocs sélectionnés sont conservés.
|
||||
- Pastilles de couleurs inline : remplacées par le sous-menu Color nommé
|
||||
(palette portée à 10 couleurs alignées Notion : +Brown, Pink réel).
|
||||
|
||||
## v7.60.0 (2026-10-07) — Éditeur : bouton + à gauche du handle de bloc
|
||||
|
||||
### Added
|
||||
|
||||
- **Bouton `+` à gauche du handle** (les 6 points qui ouvrent le menu ⋮⋮ /
|
||||
servent au drag) : clique → **nouveau bloc inséré sous le bloc courant**,
|
||||
via le mécanisme déjà en place (`E.addAfter(idx)` → `addAt(idx+1)`).
|
||||
- Positionné dans la gouttière (`left:-64px`, gouttière `.blocks-container`
|
||||
de 64px, `-56px` quand elle passe à 32px), apparaît au survol/focus du bloc
|
||||
comme le handle, masqué avec lui sur écran étroit (≤768px et le second
|
||||
breakpoint — aucun débordement horizontal ajouté au conteneur.
|
||||
- `aria-label` + `title` « Add block below » (accessibilité).
|
||||
- **Tests** — `tests/test_v760_block_insert_btn.py` : **2 tests** (ordre
|
||||
dans le wrapper, câblage `E.addAfter`, positions/masquages CSS).
|
||||
|
||||
## v7.59.3 (2026-10-07) — Correction : « Turn into » du menu contextuel
|
||||
|
||||
### Fixed
|
||||
|
||||
- **« Turn into… » ne faisait rien** — la garde validait le type avec
|
||||
`_BLOCK_TURN_TYPES.indexOf(type)` alors que ce tableau contient des **objets**
|
||||
`{id, name}` : `indexOf` d'une chaîne renvoyait toujours `-1` → `turnInto()`
|
||||
retournait immédiatement après l'ouverture du sous-menu. Correction :
|
||||
`_BLOCK_TURN_TYPES.some(t=>t.id===type)`.
|
||||
- **Texte conservé** — convertir un bloc en **To-do** ou **Toggle** vidait le
|
||||
contenu (ces deux types étaient exclus de la conservation par erreur) ; seul
|
||||
un type sans champ texte (`image`, `embed`, `divider`, `button`, `columns`,
|
||||
…) le vide désormais, comme dans `Notion`.
|
||||
- **Tests** — `tests/test_v7593_turn_into.py` : **4 tests**, dont 1
|
||||
comportemental **node** qui exécute la vraie `turnInto()` extraite de la
|
||||
source : conversion effective du type, texte conservé (To-do = étiquette +
|
||||
case décochée), contenu vidé pour un type sans champ texte.
|
||||
|
||||
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Aide** — bouton **✕** en haut à droite du panneau (classe partagée
|
||||
`settings-close` : 32 px desktop / **44 px mobile**) + méthode `close()` dans
|
||||
`help.js` (retour à la page précédente, sinon `/workspaces`) ;
|
||||
`.settings-panel` passé en `position:relative` pour ancrer les boutons au
|
||||
panneau (hamburger + ✕) plutôt qu'à la page.
|
||||
- **Éditeur — « Insert below » (/Write with AI)** : on insère le **markdown
|
||||
source** conservé à la génération (`AIC._md`) au lieu du texte extrait du
|
||||
HTML rendu (`_resultText()` ne reste qu'en repli pour les messages d'erreur) —
|
||||
titres, listes, gras et numéros survivent à l'insertion.
|
||||
- **Listes numérotées** — elles n'avaient **aucun numéro au rendu** : le CSS
|
||||
ne déclarait que le retrait, jamais le marqueur. Le rang des blocs
|
||||
`numbered_list` contigus est désormais calculé au rendu (`data-num`) et
|
||||
affiché via `content: attr(data-num) ". "` → **`/numbered list` et
|
||||
l'insertion depuis l'assistant affichent enfin 1, 2, 3**.
|
||||
- **`md2b`** (markdown → blocs, chemin agent) : les cases à cocher `- [ ]` /
|
||||
`- [x]` sont testées **avant** la branche des puces (une ligne `- [ ]`
|
||||
était avalée par cette branche) — aligné sur l'ordre déjà correct de
|
||||
`paste2b` ; le séparateur `1)` est accepté (CommonMark).
|
||||
- **Menu slash** — une requête tapée **dans le bloc** (`/numbered list`, focus
|
||||
qui n'a pas rejoint l'entrée du menu) est répercutée dans le filtre au lieu
|
||||
d'être ignorée.
|
||||
- **Tests** — `tests/test_v7592_ui_fixes.py` : **5 tests**, dont 1 test
|
||||
comportemental **node** qui exécute le vrai `md2b` extrait de la source.
|
||||
- `ruff` : ordre d'imports de `tests/test_agent.py` (I001 préexistant au pull).
|
||||
|
||||
## v7.59.1 (2026-10-07) — Fix fournisseur Mistral (clé valide, 403 tier_not_allowed)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mistral « Test connection » échouait malgré une clé valide** — cause
|
||||
racine : `mistral-large-latest` (modèle par défaut + tête de liste) n'est
|
||||
pas servi par les plans d'abonnement basiques → `403 tier_not_allowed`
|
||||
(code 1910). La clé elle-même fonctionne (`/v1/models` 200, chat 200 sur
|
||||
small/medium/nemo/codestral — vérifié en direct).
|
||||
- `PROVIDERS["mistral"]` default → `mistral-small-latest` (tous plans).
|
||||
- `PROVIDER_MODELS["mistral"]` réordonnée : modèles tous-plans d'abord
|
||||
(le test de connexion sélectionne le premier candidat).
|
||||
- `mistral` ajouté à `_CHAT_VALIDATED_PROVIDERS` : le fetch des modèles
|
||||
sonde chat/completions et exclut les modèles hors plan (46 listés →
|
||||
25 réellement utilisables avec la clé du compte).
|
||||
- **Migration 36** : `default_model` des lignes mistral pointant sur un
|
||||
modèle bloqué réinitialisé (→ nouveau défaut), `llm_config.model` idem.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_agent.py::test_mistral_defaults_are_tier_safe` · suite agent
|
||||
OK · test de connexion live sur l'instance déployée : `{"ok":true,
|
||||
"model":"mistral-small-latest","reply":"PONG","verified":true}`.
|
||||
|
||||
## v7.59.0 (2026-10-07) — Mobile : drawer réglable, side-nav settings, aide refondue
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scroll souris du drawer sidebar mobile** — `display:block` du breakpoint
|
||||
≤768px cassait le `flex:1` de `.sidebar-scroll` (hauteur = contenu, jamais
|
||||
de zone scrollable). Remis en `display:flex;flex-direction:column`.
|
||||
- **Rate limiter du routeur de test** — fenêtre remise à zéro à chaque login
|
||||
: suite e2e via un seul login `beforeAll` + cookies `storageState`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Settings mobile : le menu devient une side-navigation gauche** ouverte
|
||||
par un hamburger (`.settings-menu-btn`) avec backdrop, au lieu des onglets
|
||||
horizontaux ; fermeture auto au choix de section. Squelette panneau
|
||||
(overlay/panel/nav) extrait de `settings.html` vers
|
||||
`/static/css/settings.css` partagé avec l'aide.
|
||||
- **Page `/help` refondue au complet** — ancien monologue HTML inline de la
|
||||
route remplacé par `app/templates/help.html` : même panneau 2 colonnes que
|
||||
settings, nav latérale en 13 sections couvrant toute l'app (démarrer,
|
||||
éditeur, bases & vues, tâches/dépendances, workspaces/forges, collaboration,
|
||||
bibliothèque/recherche, agent IA & automations, API v2/webhooks/clipper,
|
||||
PWA offline, comptes & SSO, raccourcis, tips) ; `static/js/help.js`
|
||||
(`Alpine.data helpInit`).
|
||||
|
||||
### Tests
|
||||
|
||||
- Gate e2e `mobile_regression.spec.js` : 6/6 (drawer + wheel scroll +
|
||||
side-nav settings + aide mobile/desktop) · `test_app.py` +
|
||||
`test_pwa_offline.py` : 230/230.
|
||||
|
||||
## 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
|
||||
|
||||
- **Presets Discord / Telegram** — champ **Type** dans le formulaire « Ajouter
|
||||
un connecteur » (Personnalisé / Discord / Telegram / Serveur MCP) : URL et
|
||||
schéma d'authentification pré-remplis. Colonnes `kind` + `auth`
|
||||
(`bearer`/`bot`/`none`) ajoutées à `agent_connectors` (migration **34**) ;
|
||||
Discord envoie son jeton préfixé dans l'en-tête d'autorisation, Telegram place
|
||||
le jeton **dans l'URL** via le substitut `{secret}` — substitué à l'appel,
|
||||
**jamais stocké en clair** (la colonne url ne contient que le motif).
|
||||
- **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
|
||||
(consentement admin requis à la reconnexion).
|
||||
- **`app/services/mcp_client.py`** — client MCP (streamable HTTP) : handshake
|
||||
`initialize` → `notifications/initialized` → `tools/list` → `tools/call`
|
||||
(JSON-RPC 2.0, réponse JSON **ou** premier `data:` d'un `text/event-stream`),
|
||||
garde SSRF conservée, erreurs JSON-RPC remontées telles quelles, sortie
|
||||
bornée à 20 000 car. Les outils sont **cachés** en base (`tools_json`) — le
|
||||
bouton « Tester le connecteur » rejoue le handshake et met à jour la liste.
|
||||
- **Outils dynamiques au registre** — `ToolRegistry._all()` merge ce cache à
|
||||
chaque run : chaque outil est exposé au LLM sous `mcp_<serveur>_<outil>` avec
|
||||
son `inputSchema` d'origine, et `execute()` dispatche sur `tools/call`.
|
||||
Serveur désactivé → outil **absent du schéma** ; échec réseau →
|
||||
`ToolResult(error)` sans casser le run.
|
||||
- **Menu +** — badge `· N outil(s)` sur la fiche d'un serveur MCP ;
|
||||
`POST /api/agent/connectors` accepte `kind` + `auth` (400 sur valeurs
|
||||
inconnues).
|
||||
- **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel réseau
|
||||
réel (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la base,
|
||||
handshake + séquence d'appels, cache, dispatch LLM, serveur désactivé,
|
||||
erreurs RPC, parse SSE, scopes Teams, câblage menu.
|
||||
|
||||
## v7.56.0 (2026-10-06) — Connecteurs : Google + Microsoft 365 (phase 6/8)
|
||||
|
||||
### Added
|
||||
|
||||
- **`app/services/oauth_connectors.py`** — flow OAuth2 complet **PKCE (S256)** pour
|
||||
2 fournisseurs décrits par 1 dict :
|
||||
- **Google** : Drive / Gmail / Calendar **en lecture seule** ;
|
||||
- **Microsoft 365** : Graph `Files.Read` / `Mail.Read` / `Calendars.Read` ;
|
||||
- `begin()` → URL d'autorisation + state + code_verifier ; `complete()` →
|
||||
échange du code ; `access_token()` → **refresh automatique** (60 s avant
|
||||
expiration, `refresh_token` conservé si le fournisseur n'en renvoie pas) ;
|
||||
`api_get()` → lecture bornée, `path` absolu refusé + validation SSRF ;
|
||||
- tokens chiffrés **Fernet** via `_encrypt_tokens` de `calendar_sync`
|
||||
(réutilisation, aucune nouvelle dépendance) dans la table
|
||||
**`connector_tokens`** (migration **33**, PK `(kind, user_id)`).
|
||||
- **4 routes OAuth** (`/api/agent/connectors/oauth/{kind}/…`) : `status`,
|
||||
`authorize` (cookies d'état HttpOnly 10 min + retour same-origin validé),
|
||||
`callback` (GET = SAFE_METHODS, `state` comparé en temps constant, tokens
|
||||
stockés puis cookies purgés, redirection `?oauth=connected` /
|
||||
`?oauth_error=…`), `disconnect`. **OpenAPI : 523 chemins**.
|
||||
- **Config + `.env.example`** : `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET`
|
||||
(vidés = « non configuré »), redirect URI dérivé d'APP_BASE_URL.
|
||||
- **Catalogue** : les 2 nouveaux natifs apparaissent avec le badge
|
||||
« non connecté — lancer la connexion OAuth » / « connecté · N scope(s) » ;
|
||||
`connector_fetch` et `Tester` passent par l'API Graph/Google avec le token de
|
||||
l'utilisateur (outil LLM = `user_id` désormais transmis).
|
||||
- **Menu +** : « Se connecter » (redirige vers le fournisseur) / « Déconnecter »
|
||||
sur la fiche du connecteur, + toast au retour du flux (URL nettoyée via
|
||||
`history.replaceState`).
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v756_oauth_connectors.py` — **13 tests** : URL d'autorisation
|
||||
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
|
||||
**chiffrés** en base + redirection + `status.connected`, **state forgé refusé
|
||||
sans aucun appel réseau**, callback sans session → `oauth_error=session`,
|
||||
refresh (grant_type, conservation du refresh_token), `api_get` (Bearer + hôte
|
||||
fixe + URL absolue refusée), non connecté, déconnexion, catalogue, outil LLM,
|
||||
401 sans session, 404 kind inconnu, câblage menu. Aucun appel réseau réel
|
||||
(`_post_form` / `_api_get` monkeypatchés).
|
||||
- `test_v755` adapté : **5 natifs** (gitea, github, web, google, ms365).
|
||||
- **Suite complète : 1319 passed / 0 failed** (`-n auto`), `ruff` 0,
|
||||
`eslint` 0 problème.
|
||||
|
||||
|
||||
## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8)
|
||||
|
||||
### Added
|
||||
|
||||
- **Table `agent_connectors`** (migration **32**) : `name`, `url`,
|
||||
`secret_encrypted`, `enabled`, `status`, `detail` — colonnes **plates**, pas de
|
||||
`config_json` (les scopes OAuth des phases 6-7 ajouteront le leur).
|
||||
- **`app/services/connectors.py`** — 1 fichier (pas de package) :
|
||||
- **3 connecteurs natifs** (`gitea`, `github`, `web`) servis à la volée, statut
|
||||
dérivé de la config **sans réseau** (`native_state()`) ;
|
||||
- CRUD des connecteurs personnalisés : l'URL passe par **`_validate_url`
|
||||
(garde SSRF)** à la création, la clé est **chiffrée Fernet** (`encrypt_secret`)
|
||||
et **jamais renvoyée** (seul `has_secret` l'est) ;
|
||||
- `probe()` : appel de contrôle (`_get`, timeout 10 s, re-vérification de
|
||||
l'hôte après redirection) qui **persiste** `status`/`detail` ;
|
||||
- `connector_fetch()` : lecture pour l'LLM — API Gitea/GitHub (native),
|
||||
recherche web (native) ou GET du connecteur personnalisé, borné à 20 000 car.
|
||||
- **API** (`/api/agent/connectors`) — `GET` (catalogue), `POST` (création, 400 sur
|
||||
URL privée), `PATCH` (name/enabled/secret), `DELETE`, `POST /connectors/probe`
|
||||
; session requise partout (401), CSRF global. **OpenAPI : 519 chemins**.
|
||||
- **Outil LLM `connector_fetch`** (`tool_registry`, 26ᵉ outil) — args
|
||||
`connector` (id / nom / kind), `path`, `query` ; connecteur désactivé ou
|
||||
inconnu = erreur outil, **jamais une exception qui casse le run**.
|
||||
- **Menu + : section « Connecteurs »** — catalogue avec badge de statut
|
||||
(✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — les
|
||||
2 derniers masqués pour les natifs) et formulaire « Ajouter un connecteur
|
||||
personnalisé » (nom, URL, clé en `type="password"`).
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v755_connectors.py` — **13 tests** : 3 natifs listés, clé jamais
|
||||
renvoyée + chiffrée en base (roundtrip Fernet), **5 URLs refusées** (localhost,
|
||||
127.0.0.1, metadata cloud, ftp:, file:), toggle persisté + 404, 401 sans
|
||||
session, probe OK (clé bien envoyée) / probe erreur persistée, outil
|
||||
`connector_fetch` (schéma, succès, désactivé, inconnu), câblage du menu.
|
||||
- `test_v751`/`test_v753`/`test_v754` adaptés : **1 seule section « bientôt »**
|
||||
(plugins).
|
||||
- **Suite complète : 1306 passed / 0 failed** (`-n auto`), `ruff` 0,
|
||||
`eslint` 0 problème.
|
||||
|
||||
|
||||
## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8)
|
||||
|
||||
### Added
|
||||
|
||||
- **Mémoire de l'agent** — le moteur n'envoie jamais l'historique des messages
|
||||
(`AgentEngine.run()` ne construit que système + objectif courant) : chaque run
|
||||
repartait de zéro. Désormais une **ligne résumé par conversation**
|
||||
(`app/services/agent_memory.py`, table `agent_memory`, migration 31) est
|
||||
écrite à la fin de chaque run et **ré-injectée au contexte du run suivant**
|
||||
quand le toggle est activé.
|
||||
- `context_for()` : bloc « ## Mémoire de la conversation », **budget 4 000
|
||||
caractères** (troncature par la gauche avec marqueur) ;
|
||||
- `remember()` : 1 note par échange (objectif 180 car. → réponse 700 car.),
|
||||
**20 notes max**, upsert, et **jamais une erreur ne fait échouer un run** ;
|
||||
- OFF = ni lecture ni écriture (contexte strict du run courant).
|
||||
- **Toggle « Mémoire » dans le menu +** — entrée racine à l'état réel
|
||||
(🧠 activée / 💭 désactivée avec le libellé correspondant), bascule via
|
||||
`PATCH /api/agent/conversations/{id}` (`memory_enabled` ajouté au whitelist),
|
||||
état persisté et reflété à l'ouverture/à la création de la conversation.
|
||||
- **`agent_conversations.memory_enabled`** (migration **31**, défaut `1`) +
|
||||
config **`AGENT_MEMORY_DEFAULT`** (`settings.agent_memory_default`) appliquée
|
||||
à la création d'une conversation ; la réponse de création expose la valeur.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v754_memory.py` — **8 tests** : colonne + table créées par la
|
||||
migration, défaut ON à la création, toggle persisté relu en base,
|
||||
injection ON / absence OFF / persistance à travers un cycle OFF→ON,
|
||||
budget et nombre de notes bornés, **2 runs réels du moteur** (FakeLLM qui
|
||||
capture le prompt : mémoire présente ON, absente OFF), câblage front/back.
|
||||
- `test_v751` + `test_v753` adaptés : **2 sections « bientôt »** restantes
|
||||
(connecteurs, plugins).
|
||||
- **Suite complète : 1293 passed / 0 failed** (`-n auto`), `ruff` 0,
|
||||
`eslint` 0 problème.
|
||||
|
||||
|
||||
## v7.53.0 (2026-10-06) — Menu + : Design System – Canevas (phase 3/8)
|
||||
|
||||
### Added
|
||||
|
||||
- **Section « Design System – Canevas »** — liste des canevas
|
||||
(`GET /board/api/page-templates` : intégrés + personnels), puis une page de
|
||||
détail avec 3 actions :
|
||||
- **Créer une page à partir du canevas** (`POST …/0/use` avec `{key}` *dans le
|
||||
body* pour un intégré, `POST …/{id}/use` pour un personnel) ;
|
||||
- **Insérer dans le document ouvert** — `GET …/blocks` (nouvelle route) →
|
||||
`ensureBlockIds()` (global côté éditeur) → concaténation dans `E.blocks` +
|
||||
`autoSave/render`, désactivé si aucun document éditable n'est ouvert ;
|
||||
- **Enregistrer le document ouvert comme canevas**
|
||||
(`POST /board/api/page-templates` avec `page_id`).
|
||||
- **`GET /board/api/page-templates/{template_id}/blocks`** — blocs d'un canevas
|
||||
( intégré via `template_id=0&key=`, sinon id) ; 404 clé/id inconnu,
|
||||
500 si le JSON est corrompu. **OpenAPI : 516 chemins**, `info.version` 7.53.0.
|
||||
- **Canevas « Design System »** intégré (`design_system` dans
|
||||
`app/services/block_templates.py`) : callout tokens, TOC, 3 toggles
|
||||
composants, grille & espacement, checklist revue UI, citation.
|
||||
- **Navigation** : en-tête ← gère le niveau canevas → liste.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Prefixe des canevas** : le front du panneau utilisait `/api/page-templates`
|
||||
(404 silencieux) — la route réelle est `/board/api/page-templates` (router
|
||||
`prefix="/board"`), comme `base.html` le fait déjà.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v753_canvases.py` — **5 tests** : canevas `design_system` listé,
|
||||
route blocks intégré (sans `id`, 404 clé inconnue, `empty` par défaut),
|
||||
route blocks personnel + 404, création de page depuis le canevas (blocs lus
|
||||
en base, ids assignés), câblage des sections (3 « bientôt » restantes) +
|
||||
garde-fou « `key` dans le body ». `test_v751` adapté (3 sections désactivées).
|
||||
- **Suite complète : 1285 passed / 0 failed** (`-n auto`), `ruff` 0,
|
||||
`eslint` 0 problème.
|
||||
|
||||
|
||||
## v7.52.0 (2026-10-06) — Menu + : Compétences-skills « Gérer » + « Parcourir » (phase 2/8)
|
||||
|
||||
### Added
|
||||
|
||||
+146
-52
@@ -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-2 ✅ 2026-10-06 · phases 3-8 planifiées)
|
||||
## 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é :
|
||||
@@ -366,71 +366,165 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.52.0**, OpenAPI régénéré
|
||||
(515 chemins), CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 3 — v7.53.0 — Design System – Canevas · effort M
|
||||
### Phase 3 — v7.53.0 — Design System – Canevas · effort M ✅ (livrée 2026-10-06)
|
||||
|
||||
- [ ] **Sous-menu Canevas** — `GET /api/page-templates` (built-ins + personnels)
|
||||
- [ ] **Deux actions** — « Créer une page à partir du canevas »
|
||||
(`POST /api/page-templates/{id}/use`) et « Insérer dans le document ouvert »
|
||||
(blocs poussés dans l'éditeur quand un document est ouvert)
|
||||
- [ ] **Canevas « design system »** — presets maison dans `block_templates.py`
|
||||
(callout, TOC, colonnes, grille de composants) réutilisant `design-tokens.css`
|
||||
- [ ] **Sauver le document ouvert comme canevas** — `POST /api/page-templates` existe
|
||||
- [ ] **Tests** — création, insertion dans l'éditeur, sauvegarde
|
||||
- [x] **Sous-menu Canevas** — `GET /board/api/page-templates` (built-ins **+
|
||||
personnels**) ; **piège confirmé** : le router est sous `prefix="/board"` →
|
||||
l'URL réelle est `/board/api/page-templates`, pas `/api/page-templates`
|
||||
(404 silencieux corrigé dans le front)
|
||||
- [x] **Deux actions (3 en réalité)** — page de détail par canevas :
|
||||
« Créer une page à partir du canevas » (`POST …/0/use` avec `{key}` **dans le
|
||||
body** — la clé en query est ignorée par la route), « Insérer dans le document
|
||||
ouvert » (`GET …/blocks` **nouvelle route** → `ensureBlockIds()` global →
|
||||
concat dans `E.blocks` + `autoSave/render`, désactivé sans document ouvert)
|
||||
et « Enregistrer le document ouvert comme canevas » (`POST` + `page_id`)
|
||||
- [x] **Canevas « design system »** — preset `design_system` dans
|
||||
`block_templates.py` (callout tokens, TOC, toggles composants, grille,
|
||||
checklist revue UI) ; `design-tokens.css` référencé dans le callout
|
||||
- [x] **Sauver le doc ouvert comme canevas** — `POST /board/api/page-templates`
|
||||
(existante) branchée depuis le menu, nom via `prompt()` natif
|
||||
- [x] **Tests** — `tests/test_v753_canvases.py` : **5 tests** (preset listé,
|
||||
route blocks intégré/personnel + 404, création de page vérifiée en base avec
|
||||
ids, câblage 3 « bientôt » + garde-fou `key`-dans-le-body) ; `test_v751`
|
||||
adapté ; **suite complète 1285 verts**, `ruff` 0, `eslint` 0 problème
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.53.0**, OpenAPI régénéré
|
||||
(516 chemins), CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M
|
||||
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M ✅ (livrée 2026-10-06)
|
||||
|
||||
- [ ] **Table `agent_memory`** (`workspace_id`, `kind` summary|fact, `content`,
|
||||
`updated_at`) + colonne `conversations.memory_enabled`
|
||||
- [ ] **Service `app/services/agent_memory.py`** — extraction en fin de run (résumé +
|
||||
faits saillants ; LLM sans outils, repli déterministe hors-ligne)
|
||||
- [ ] **Injection dans `ContextBuilder` si le toggle est ON** ; OFF = contexte strict
|
||||
de la conversation courante
|
||||
- [ ] **Toggle « Mémoire »** dans le menu + : état visible, persisté par conversation,
|
||||
défaut `AGENT_MEMORY_DEFAULT`
|
||||
- [ ] **Tests** — injection ON / absence OFF / persistance / budget de tokens borné
|
||||
- [x] **Table `agent_memory`** — **une ligne résumé par conversation**
|
||||
(`conversation_id` PK, upsert) + colonne `agent_conversations.memory_enabled`
|
||||
(migration **31**, défaut `1`) ; la variante « mémoire d'espace » prévue au
|
||||
design est **remise** (YAGNI : rien ne l'écrit — `ponytail:` noté dans le service)
|
||||
- [x] **Service `app/services/agent_memory.py`** — **extraction déterministe**
|
||||
(pas d'LLM) : 1 note par échange (objectif 180 car. → réponse 700 car.),
|
||||
20 notes max, écrite en fin de run via `agent_memory.remember()` ; toute
|
||||
erreur est journalisée, **un run ne peut pas échouer à cause de la mémoire**
|
||||
- [x] **Injection** — `AgentEngine.run()` appelle `agent_memory.context_for()` après
|
||||
`ContextBuilder.build()` : bloc « ## Mémoire de la conversation », **budget
|
||||
4 000 caractères** (troncature par la gauche + marqueur) ; OFF = ni lecture ni
|
||||
écriture → contexte strict du run courant
|
||||
- [x] **Toggle « Mémoire »** dans le menu + — entrée racine à l'état réel
|
||||
(🧠/💭 + libellé), `PATCH /api/agent/conversations/{id}` (`memory_enabled`
|
||||
ajouté au whitelist), état lu à l'ouverture et à la création, défaut
|
||||
`AGENT_MEMORY_DEFAULT` (config + réponse de création)
|
||||
- [x] **Tests** — `tests/test_v754_memory.py` : **8 tests** dont **2 runs réels du
|
||||
moteur** (FakeLLM capture le prompt : mémoire présente ON / absente OFF),
|
||||
budget et nombre de notes bornés, toggle relu en base ; `test_v751` +
|
||||
`test_v753` adaptés (2 sections « bientôt ») ; **suite complète 1293 verts**,
|
||||
`ruff` 0, `eslint` 0 problème
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré
|
||||
(516 chemins), CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L
|
||||
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L ✅ (livrée 2026-10-06)
|
||||
|
||||
- [ ] **Table `agent_connectors`** (`kind`, `name`, `config`, credentials chiffrés,
|
||||
`enabled`, `status`)
|
||||
- [ ] **`app/services/connectors/`** — interface commune `probe() / list_sources() / fetch()`
|
||||
+ 3 adapters **natifs déjà présents** : Gitea, GitHub, Web (`web_search` / `fetch_url`, v7.46)
|
||||
- [ ] **Menu « Connecteurs »** — « Parcourir les connecteurs » (catalogue + statut
|
||||
connecté/déconnecté) et « Ajouter un connecteur personnalisé » (URL + clé + type
|
||||
HTTP / MCP)
|
||||
- [ ] **Tools agent** — un tool par connecteur exposé au LLM via `tool_registry`
|
||||
- [ ] **Sécurité** — garde SSRF `_is_public_host` sur toute URL, credentials chiffrés,
|
||||
session requise, CSRF sur les écritures
|
||||
- [ ] **Tests** — CRUD, statut, vecteurs SSRF, schéma des tools
|
||||
- [x] **Table `agent_connectors`** (migration **32**) — colonnes **plates**
|
||||
(`name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail`) ; le
|
||||
`config_json` prévu est **remis** (YAGNI : rien ne le remplissait — les scopes
|
||||
OAuth des phases 6-7 ajouteront le leur)
|
||||
- [x] **`app/services/connectors.py`** — **1 fichier au lieu du package
|
||||
`connectors/`** promis (3 probes + 1 fetch ne justifient pas une arborescence) ;
|
||||
3 natifs `gitea`/`github`/`web` servis à la volée, statut **sans réseau**
|
||||
(`native_state()`) ; `probe()` et `connector_fetch()` passent par `_get()`
|
||||
(gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.)
|
||||
- [x] **Menu « Connecteurs »** — « Parcourir » = catalogue avec badge
|
||||
(✓ ok / ✗ error / ⚠ missing / ? inconnu) + fiche (Tester, Activer/Désactiver,
|
||||
Supprimer — masqués pour les natifs) ; « Ajouter un connecteur personnalisé » =
|
||||
formulaire nom + URL + clé (`type="password"`)
|
||||
- [x] **Tool agent** — **un seul `connector_fetch`** (id/nom/kind + `path` +
|
||||
`query`) au lieu d'un outil par connecteur : même capacité, moins de bruit de
|
||||
schéma — l'outil dispatche vers Gitea/GitHub/web/personnalisé. Connecteur
|
||||
désactivé ou inconnu = erreur outil, jamais une exception de run
|
||||
- [x] **Sécurité** — `_validate_url` (SSRF) à la création **et** à chaque appel,
|
||||
clé **chiffrée Fernet** et jamais renvoyée (`has_secret` seulement), 401 sans
|
||||
session sur les 5 routes, CSRF global
|
||||
- [x] **Tests** — `tests/test_v755_connectors.py` : **13 tests** (3 natifs, clé
|
||||
chiffrée roundtrip, **5 URLs refusées** : localhost / 127.0.0.1 / metadata
|
||||
cloud / ftp: / file:, toggle persisté, 401, probe OK + probe erreur persistée,
|
||||
outil complet, câblage menu) ; 3 autres fichiers de phase adaptés (1 section
|
||||
« bientôt » restante) ; **suite complète 1306 verts**, `ruff` 0, `eslint` 0
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré
|
||||
(519 chemins), CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L
|
||||
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L ✅ (livrée 2026-10-06)
|
||||
|
||||
- [ ] **OAuth2 PKCE Google** — Drive, Gmail, Calendar : connexion, refresh, déconnexion,
|
||||
lecture limitée aux sources choisies
|
||||
- [ ] **OAuth2 Microsoft (Graph)** — Outlook / OneDrive / SharePoint
|
||||
- [ ] **Écran connecteur** — état, scopes, dernière synchro, bouton déconnecter
|
||||
- [ ] **Tests** — callbacks, refresh, échec, **aucun appel réseau réel** (transport injecté)
|
||||
- [x] **OAuth2 PKCE Google** — Drive / Gmail / Calendar **en lecture seule** ;
|
||||
connexion (`begin()` → URL + state + `code_verifier` S256), échange du code,
|
||||
**refresh automatique** (60 s de marge, `refresh_token` conservé si absent de
|
||||
la réponse), déconnexion — table `connector_tokens` (migration **33**,
|
||||
PK `(kind, user_id)`), tokens chiffrés Fernet **réutilisant**
|
||||
`calendar_sync._encrypt_tokens` (zéro dépendance ajoutée)
|
||||
- [x] **OAuth2 Microsoft (Graph)** — `Files.Read` / `Mail.Read` / `Calendars.Read`
|
||||
+ `offline_access`, même code (2 providers décrits par **1 dict**)
|
||||
- [x] **Écran connecteur** — **pas de page dédiée** : l'état vit dans la fiche du
|
||||
menu + (badge « connecté · N scope(s) » / « non connecté »), boutons
|
||||
**Se connecter / Déconnecter**, toast au retour du flux (`?oauth=connected` /
|
||||
`?oauth_error=` nettoyés par `history.replaceState`)
|
||||
- [x] **Tests** — `tests/test_v756_oauth_connectors.py` : **13 tests**, **0 appel
|
||||
réseau réel** (`_post_form` / `_api_get` monkeypatchés) — URL d'autorisation
|
||||
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
|
||||
chiffrés + redirection, **state forgé refusé sans échange**, callback sans
|
||||
session, refresh, `api_get` (Bearer, URL absolue refusée), déconnexion,
|
||||
catalogue, outil LLM, 401/404, câblage menu ; `test_v755` adapté (5 natifs) ;
|
||||
**suite complète 1319 verts**, `ruff` 0, `eslint` 0
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.56.0**, `.env.example`
|
||||
(GOOGLE_/MS_ CLIENT_ID/SECRET + redirect URI), OpenAPI régénéré (523 chemins),
|
||||
CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L
|
||||
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L ✅
|
||||
|
||||
- [ ] **Discord** (bot token : lecture canaux/DMs), **Telegram** (Bot API),
|
||||
**Teams** (via Graph de la phase 6)
|
||||
- [ ] **MCP complet** — handshake `initialize`, `tools/list`, `tools/call`
|
||||
(streamable HTTP) → outils **dynamiques** ajoutés au registre à la connexion
|
||||
- [ ] **Tests** — schémas MCP, mapping d'outils, échecs d'auth
|
||||
- [x] **Presets Discord / Telegram** — champ **Type** dans le formulaire
|
||||
« Ajouter un connecteur » : `kind` + `auth` (`bearer`/`bot`/`none`) ajoutés à
|
||||
`agent_connectors` (migration **34**) ; Discord = en-tête d'autorisation avec
|
||||
le jeton préfixé, Telegram = jeton **dans l'URL** via le substitut `{secret}`
|
||||
(remplacé à l'appel, **jamais stocké en clair** — colonne url = `…/bot{secret}`)
|
||||
- [x] **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
|
||||
(consentement admin)
|
||||
- [x] **MCP complet** — `app/services/mcp_client.py` : `initialize` →
|
||||
`notifications/initialized` → `tools/list` → `tools/call` (JSON-RPC 2.0,
|
||||
réponse JSON **ou** 1er `data:` d'un `text/event-stream`), garde SSRF,
|
||||
outils **cachés** en base (`tools_json`, maj par le bouton « Tester »)
|
||||
- [x] **Outils dynamiques au registre** — `ToolRegistry._all()` merge le cache
|
||||
MCP à chaque run : nom `mcp_<serveur>_<outil>` + `inputSchema` exposés au
|
||||
LLM, `execute()` dispatche sur `tools/call` ; serveur désactivé → outil
|
||||
**absent du schéma** ; erreur RPC → `ToolResult(error)` (jamais de run perdu)
|
||||
- [x] **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel
|
||||
réseau (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la
|
||||
base, kind/auth invalides → 400, handshake + séquence d'appels, cache
|
||||
`tools_json`, dispatch LLM, serveur désactivé, erreur handshake → probe
|
||||
error, parse SSE/erreurs JSON-RPC, slug, scopes Teams, câblage menu
|
||||
- [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 et 2 livrées le
|
||||
2026-10-06 (v7.51.0, v7.52.0)**, phases 3-8 à valider une par une 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.*
|
||||
|
||||
---
|
||||
|
||||
@@ -126,6 +126,19 @@ class Settings(BaseSettings):
|
||||
agent_max_tokens_budget: int = 500000
|
||||
agent_run_timeout_seconds: int = 300
|
||||
|
||||
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
|
||||
# Client OAuth2 à créer dans les consoles : Google Cloud (OAuth client web)
|
||||
# et Entra ID (application web). Le redirect URI est dérivé d'APP_BASE_URL.
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
ms_client_id: str = ""
|
||||
ms_client_secret: str = ""
|
||||
|
||||
# ── Mémoire de l'agent (v7.54.0) ──
|
||||
# État initial du toggle « Mémoire » à la création d'une conversation ;
|
||||
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
|
||||
agent_memory_default: bool = True
|
||||
|
||||
# ── Web tools de l'agent (web_search / fetch_url / search_code) ──
|
||||
# `web_search_provider` : « exa » (recherche sémantique + snippets, clé
|
||||
# requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé).
|
||||
|
||||
+11
-5
@@ -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.52.0",
|
||||
version="7.61.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)
|
||||
|
||||
@@ -1584,3 +1584,118 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_collections_task "
|
||||
"ON collections(is_task, workspace_id)"
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
serveurs MCP (kind='mcp', cache d'outils) réutilisent la même table."""
|
||||
cols = columns(conn, "agent_connectors")
|
||||
if "kind" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'")
|
||||
if "auth" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN auth TEXT NOT NULL DEFAULT 'bearer'")
|
||||
if "tools_json" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN tools_json TEXT NOT NULL DEFAULT '[]'")
|
||||
|
||||
|
||||
@register(33, "v7.56.0: tokens OAuth des connecteurs (Google / M365)")
|
||||
def _migration_connector_tokens(conn: sqlite3.Connection) -> None:
|
||||
"""Tokens OAuth par (kind, utilisateur), chiffrés Fernet côté service."""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS connector_tokens (
|
||||
kind TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
tokens_enc TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (kind, user_id)
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@register(32, "v7.55.0: connecteurs de l'agent (socle)")
|
||||
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
|
||||
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
|
||||
|
||||
Colonne ``url`` plate (pas de ``config_json``) : les scopes/OAuth des
|
||||
phases 6-7 ajouteront leur propre colonne le moment venu.
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS agent_connectors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret_encrypted TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@register(31, "v7.54.0: agent memory (mémoire de l'agent)")
|
||||
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
|
||||
"""Toggle par conversation + table des résumés mémorisés.
|
||||
|
||||
Une ligne résumé par conversation (upsert) — voir `app/services/agent_memory.py`.
|
||||
"""
|
||||
if "memory_enabled" not in columns(conn, "agent_conversations"):
|
||||
conn.execute(
|
||||
"ALTER TABLE agent_conversations "
|
||||
"ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS agent_memory (
|
||||
conversation_id INTEGER PRIMARY KEY
|
||||
REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'summary',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
|
||||
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
|
||||
"""mistral-large-latest / pixtral-large-latest renvoient 403
|
||||
« tier_not_allowed » sur les plans d'abonnement basiques : la clé est
|
||||
valide mais le test de connexion et l'agent échouaient. Reset du
|
||||
default_model stocké → vide = nouveau défaut du provider (small-latest,
|
||||
servi par tous les plans).
|
||||
"""
|
||||
blocked = ("mistral-large-latest", "pixtral-large-latest")
|
||||
conn.execute(
|
||||
"UPDATE user_llm_keys SET default_model='' "
|
||||
"WHERE provider='mistral' AND default_model IN (?,?)",
|
||||
blocked,
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE llm_config SET model='mistral-small-latest' "
|
||||
"WHERE provider='mistral' AND model IN (?,?)",
|
||||
blocked,
|
||||
)
|
||||
|
||||
+200
-6
@@ -7,15 +7,17 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
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 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 (
|
||||
@@ -199,15 +201,17 @@ def create_conversation(request: Request, body: dict = Body(default={})):
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = _default_agent(conn, user_id)
|
||||
mem_default = 1 if settings.agent_memory_default else 0
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model, memory_enabled)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
(agent["id"], user_id, body.get("title", "New conversation"),
|
||||
json.dumps({"workspace_id": ws}),
|
||||
body.get("provider", ""), body.get("model", "")),
|
||||
body.get("provider", ""), body.get("model", ""), mem_default),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
|
||||
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"),
|
||||
"status": "created", "memory_enabled": mem_default}
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
@@ -249,6 +253,10 @@ def patch_conversation(request: Request, conversation_id: int, body: dict = Body
|
||||
if body.get(col) is not None:
|
||||
sets.append(f"{col}=?")
|
||||
params.append(str(body[col]))
|
||||
# v7.54.0 — toggle « Mémoire » de la conversation (0/1).
|
||||
if body.get("memory_enabled") is not None:
|
||||
sets.append("memory_enabled=?")
|
||||
params.append(1 if body["memory_enabled"] else 0)
|
||||
params.append(conversation_id)
|
||||
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
@@ -627,6 +635,192 @@ def delete_skill(request: Request, skill_id: int):
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Connecteurs (v7.55.0) ──
|
||||
# ponytail : catalogue partagé au même titre que les skills (`list_skills`
|
||||
# n'a pas non plus de filtre par créateur) — la clé n'est jamais renvoyée.
|
||||
|
||||
|
||||
@router.get("/connectors")
|
||||
def list_connectors_route(request: Request):
|
||||
"""Catalogue : natifs (statut dérivé de la config) + personnels."""
|
||||
user_id = _current_user_id(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)."""
|
||||
_current_user_id(request)
|
||||
try:
|
||||
return connectors.create_connector(
|
||||
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
|
||||
kind=str(body.get("kind") or "custom"),
|
||||
auth=str(body.get("auth") or "bearer"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/connectors/{connector_id}")
|
||||
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
|
||||
_current_user_id(request)
|
||||
try:
|
||||
row = connectors.update_connector(
|
||||
connector_id,
|
||||
name=body.get("name"),
|
||||
enabled=body.get("enabled"),
|
||||
secret=body.get("secret"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Connecteur introuvable")
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/connectors/{connector_id}")
|
||||
def delete_connectors_route(request: Request, connector_id: int):
|
||||
_current_user_id(request)
|
||||
if not connectors.delete_connector(connector_id):
|
||||
raise HTTPException(status_code=404, detail="Connecteur introuvable")
|
||||
return {"id": connector_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/connectors/probe")
|
||||
async def probe_connectors_route(request: Request, body: dict = Body(default={})):
|
||||
"""Teste un connecteur (id, nom ou kind natif) et persiste le résultat."""
|
||||
target = str(body.get("connector") or "").strip()
|
||||
if not target:
|
||||
raise HTTPException(status_code=400, detail="connector est requis")
|
||||
user_id = _current_user_id(request)
|
||||
try:
|
||||
return await connectors.probe(target, user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
# ── Connecteurs OAuth : Google / Microsoft 365 (v7.56.0) ──
|
||||
# Flow : POST authorize (cookies state/verifier/next) → fournisseur →
|
||||
# GET callback (échange du code, tokens chiffrés) → redirection vers la page
|
||||
# d'origine. Le GET est en SAFE_METHODS : pas de CSRF (comme /auth/callback).
|
||||
|
||||
|
||||
def _oauth_kind(kind: str) -> str:
|
||||
if kind not in oauth_connectors.OAUTH_KINDS:
|
||||
raise HTTPException(status_code=404, detail="Connecteur OAuth inconnu")
|
||||
return kind
|
||||
|
||||
|
||||
def _oauth_next(request: Request, kind: str, key: str, default: str = "/") -> str:
|
||||
"""Chemin de retour same-origin (cookie `key` pour le callback)."""
|
||||
raw = request.cookies.get(key, "") or default
|
||||
path = urlparse(raw).path if raw.startswith("http") else raw
|
||||
if not path.startswith("/") or path.startswith("//"):
|
||||
return default
|
||||
return path
|
||||
|
||||
|
||||
@router.get("/connectors/oauth/{kind}/status")
|
||||
def connector_oauth_status(request: Request, kind: str):
|
||||
"""État du connecteur OAuth pour l'utilisateur courant."""
|
||||
_oauth_kind(kind)
|
||||
user_id = _current_user_id(request)
|
||||
try:
|
||||
oauth_connectors._client(kind)
|
||||
configured, detail = True, ""
|
||||
except ValueError as exc:
|
||||
configured, detail = False, str(exc)
|
||||
tok = oauth_connectors.tokens(kind, user_id)
|
||||
return {
|
||||
"kind": kind, "configured": configured, "configured_detail": detail,
|
||||
"connected": bool(tok.get("access_token")), "scope": tok.get("scope", ""),
|
||||
"expires_at": tok.get("expires_at", 0),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/connectors/oauth/{kind}/authorize")
|
||||
def connector_oauth_authorize(request: Request, kind: str):
|
||||
"""URL d'autorisation (PKCE) + cookies d'état éphémères (10 min)."""
|
||||
_oauth_kind(kind)
|
||||
_current_user_id(request)
|
||||
try:
|
||||
flow = oauth_connectors.begin(kind)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
referer = request.headers.get("referer") or ""
|
||||
next_path = urlparse(referer).path if referer else "/"
|
||||
if not next_path.startswith("/") or next_path.startswith("//"):
|
||||
next_path = "/"
|
||||
secure = request.url.scheme == "https"
|
||||
resp = JSONResponse({"url": flow["url"], "kind": kind})
|
||||
for key, value in (
|
||||
(f"fd_oauth_state_{kind}", flow["state"]),
|
||||
(f"fd_oauth_verifier_{kind}", flow["verifier"]),
|
||||
(f"fd_oauth_next_{kind}", next_path),
|
||||
):
|
||||
resp.set_cookie(key, value, max_age=600, httponly=True, samesite="lax", secure=secure)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/connectors/oauth/{kind}/callback")
|
||||
async def connector_oauth_callback(
|
||||
request: Request, kind: str, code: str = "", state: str = "", error: str = ""
|
||||
):
|
||||
"""Reçoit le code du fournisseur, échange, stocke, renvoie sur la page."""
|
||||
_oauth_kind(kind)
|
||||
next_path = _oauth_next(request, kind, f"fd_oauth_next_{kind}")
|
||||
expected = request.cookies.get(f"fd_oauth_state_{kind}", "")
|
||||
verifier = request.cookies.get(f"fd_oauth_verifier_{kind}", "")
|
||||
|
||||
def _back(flag: str) -> RedirectResponse:
|
||||
sep = "&" if "?" in next_path else "?"
|
||||
resp = RedirectResponse(f"{next_path}{sep}{flag}", status_code=302)
|
||||
for key in (f"fd_oauth_state_{kind}", f"fd_oauth_verifier_{kind}",
|
||||
f"fd_oauth_next_{kind}"):
|
||||
resp.delete_cookie(key, samesite="lax")
|
||||
return resp
|
||||
|
||||
if error:
|
||||
return _back("oauth_error=" + error[:80])
|
||||
if not code or not expected or not secrets.compare_digest(expected, state):
|
||||
return _back("oauth_error=state")
|
||||
user = get_current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
return _back("oauth_error=session")
|
||||
try:
|
||||
tokens = await oauth_connectors.complete(kind, code, verifier)
|
||||
except ValueError as exc:
|
||||
return _back("oauth_error=" + str(exc)[:80].replace(" ", "+"))
|
||||
oauth_connectors.save(kind, int(user["id"]), tokens)
|
||||
logger.info("Connector OAuth connected: %s (user #%s)", kind, user["id"])
|
||||
return _back("oauth=connected")
|
||||
|
||||
|
||||
@router.post("/connectors/oauth/{kind}/disconnect")
|
||||
def connector_oauth_disconnect(request: Request, kind: str):
|
||||
_oauth_kind(kind)
|
||||
user_id = _current_user_id(request)
|
||||
removed = oauth_connectors.clear(kind, user_id)
|
||||
return {"kind": kind, "status": "disconnected" if removed else "not-connected"}
|
||||
|
||||
|
||||
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||
|
||||
|
||||
|
||||
@@ -226,4 +226,38 @@ def use_page_template(request: Request, template_id: int, body: dict = Body(defa
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
@router.get("/api/page-templates/{template_id}/blocks")
|
||||
def page_template_blocks(request: Request, template_id: int, key: str = ""):
|
||||
"""v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.
|
||||
|
||||
Builtin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).
|
||||
Canevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le
|
||||
front appelle ``ensureBlockIds()`` (déjà global côté éditeur).
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if template_id == 0:
|
||||
from app.services.block_templates import blocks_json_for
|
||||
raw = blocks_json_for(key or "empty")
|
||||
if raw is None:
|
||||
raise HTTPException(404, "Unknown built-in template")
|
||||
else:
|
||||
uid = (user or {}).get("id")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT blocks_json FROM page_global_templates "
|
||||
"WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||
(template_id, uid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
raw = row["blocks_json"]
|
||||
try:
|
||||
blocks = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
raise HTTPException(500, "Template content corrupted") from exc
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(500, "Template content corrupted")
|
||||
return {"blocks": blocks}
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
|
||||
@@ -251,209 +251,10 @@ def accounts_page(request: Request):
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
"""Help & documentation page — settings-style panel with side navigation."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
return HTMLResponse(block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
page_title="Help",
|
||||
title_prefix="Help",
|
||||
page_icon="❓",
|
||||
content_html="""<style>
|
||||
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
|
||||
.help-hero{text-align:center;margin-bottom:48px;}
|
||||
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
|
||||
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
|
||||
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
|
||||
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
|
||||
.help-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
|
||||
.help-card .icon{font-size:20px;}
|
||||
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
|
||||
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
|
||||
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
|
||||
.help-card li::before{content:'• ';color:var(--accent);}
|
||||
.help-section{margin-bottom:48px;}
|
||||
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
|
||||
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
||||
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
||||
.help-shortcut-row:hover{background:var(--bg-hover);}
|
||||
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
||||
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
||||
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
<div class="help-page">
|
||||
<div class="help-hero">
|
||||
<h1>❓ FlowDeck Help</h1>
|
||||
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-grid">
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🚀</span>Getting Started</h3>
|
||||
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
|
||||
<ul>
|
||||
<li>Create a workspace from the <b>Workspaces</b> page</li>
|
||||
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
|
||||
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📝</span>Pages & Editor</h3>
|
||||
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
|
||||
<ul>
|
||||
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
|
||||
<li>Drag & drop pages in the sidebar to reorganize</li>
|
||||
<li>Right-click for context menu (duplicate, rename, delete)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
|
||||
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
|
||||
<ul>
|
||||
<li><span class="help-badge local">Local</span> Files stored on your server</li>
|
||||
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
|
||||
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🦎</span>Gitea Integration</h3>
|
||||
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
|
||||
<ul>
|
||||
<li>Go to <b>Settings → Integrations</b> to connect</li>
|
||||
<li>Browse repo file trees in the sidebar</li>
|
||||
<li>Create & edit files with commit messages</li>
|
||||
<li>Sync labels as tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
|
||||
<p>Share pages with collaborators or publish them to the web.</p>
|
||||
<ul>
|
||||
<li>Click <b>Share</b> in the page editor top-right</li>
|
||||
<li>Share with specific users or get a public link</li>
|
||||
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
|
||||
<p>Find all your content in one place with powerful filtering.</p>
|
||||
<ul>
|
||||
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
|
||||
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
|
||||
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📶</span>Offline & PWA</h3>
|
||||
<p>Install FlowDeck as an app and keep working without a connection.</p>
|
||||
<ul>
|
||||
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
|
||||
<li>Edits made offline are queued locally and synced automatically</li>
|
||||
<li>A <b>⟳</b> marker shows pages with pending changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>⌨️ Keyboard Shortcuts</h2>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔐 Authentication</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck supports three authentication methods:<br>
|
||||
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
|
||||
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
|
||||
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
||||
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
||||
</p>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
||||
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
||||
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
||||
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
||||
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
||||
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
||||
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
||||
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>📶 Offline mode (PWA)</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
|
||||
edits are saved locally, then synchronised when the connection returns.<br><br>
|
||||
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
|
||||
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
|
||||
<b>Offline editing:</b> while offline, the editor stores changes in the browser
|
||||
(IndexedDB) and shows an offline banner with the number of pending changes. A
|
||||
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
|
||||
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
|
||||
A spinner badge appears while syncing, followed by a confirmation toast.<br>
|
||||
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
|
||||
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
|
||||
page. If a page with the same title already exists, the offline copy is renamed
|
||||
<i>“Title (copie offline)”</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔌 API publique v2</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
||||
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
||||
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
||||
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
||||
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
||||
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
||||
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
||||
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
||||
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
||||
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>💡 Tips</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
|
||||
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
|
||||
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
|
||||
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
|
||||
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>"""
|
||||
))
|
||||
|
||||
|
||||
return ENV.get_template("help.html").render(**sidebar, request=request)
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
|
||||
@@ -18,6 +18,7 @@ import re
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import agent_memory
|
||||
from app.services.agent_policies import check_tool, get_policy
|
||||
from app.services.context_builder import ContextBuilder
|
||||
from app.services.llm_client import LLMClient
|
||||
@@ -156,6 +157,11 @@ class AgentEngine:
|
||||
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
|
||||
system = self._build_system_prompt(agent, skills)
|
||||
context = self.ctx.build(mentions=mentions, files=files)
|
||||
# v7.54.0 — mémoire de la conversation (toggle) : le moteur n'envoie
|
||||
# jamais l'historique, sans elle chaque run repart de zéro.
|
||||
memory = agent_memory.context_for(conversation_id)
|
||||
if memory:
|
||||
context = (context + "\n\n" + memory) if context else memory
|
||||
if extra_context and extra_context.strip():
|
||||
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
|
||||
|
||||
@@ -304,6 +310,8 @@ class AgentEngine:
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v7.54.0 — memorise l'echange (no-op si le toggle memoire est OFF).
|
||||
agent_memory.remember(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
await _fire_agent_webhook("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Mémoire de l'agent (v7.54.0).
|
||||
|
||||
Une ligne résumé **par conversation**, mise à jour à chaque run et ré-injectée
|
||||
au contexte du run suivant — le moteur n'envoie jamais l'historique des messages
|
||||
(`AgentEngine.run()` ne construit que system + objectif courant), donc sans
|
||||
mémoire chaque run repart de zéro.
|
||||
|
||||
Toggle : colonne `agent_conversations.memory_enabled` — OFF = aucune lecture ni
|
||||
écriture (contexte strict du run courant).
|
||||
|
||||
ponytail : mémoire par conversation seulement. Si besoin de notes partagées
|
||||
entre conversations, ajouter des lignes avec ``conversation_id NULL`` +
|
||||
``workspace_id`` et élargir la lecture dans ``context_for()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INJECT_BUDGET = 4000 # caractères de mémoire max injectés dans un prompt
|
||||
NOTE_LINES = 20 # échanges max conservés
|
||||
NOTE_OBJECTIVE = 180 # troncature de l'objectif
|
||||
NOTE_ANSWER = 700 # troncature de la réponse
|
||||
|
||||
|
||||
def _enabled(conn: sqlite3.Connection, conversation_id: int) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT memory_enabled FROM agent_conversations WHERE id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
return bool(row["memory_enabled"])
|
||||
|
||||
|
||||
def _one_line(text: str, limit: int) -> str:
|
||||
return " ".join((text or "").split())[:limit]
|
||||
|
||||
|
||||
def context_for(conversation_id: int) -> str:
|
||||
"""Bloc « mémoire » à préfixer au contexte, ou ``''`` (toggle OFF / vide)."""
|
||||
with get_conn() as conn:
|
||||
if not _enabled(conn, conversation_id):
|
||||
return ""
|
||||
row = conn.execute(
|
||||
"SELECT content FROM agent_memory WHERE conversation_id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
content = ((row["content"] if row else "") or "").strip()
|
||||
if not content:
|
||||
return ""
|
||||
if len(content) > INJECT_BUDGET:
|
||||
content = "…(début de mémoire tronqué)\n" + content[-INJECT_BUDGET:]
|
||||
return "## Mémoire de la conversation (échanges précédents)\n" + content
|
||||
|
||||
|
||||
def remember(conversation_id: int, objective: str, final_text: str) -> None:
|
||||
"""Ajoute un échange à la mémoire — no-op si le toggle est OFF.
|
||||
|
||||
Ne doit **jamais** faire échouer un run : toute erreur est journalisée.
|
||||
"""
|
||||
note = f"- **{_one_line(objective, NOTE_OBJECTIVE)}** → {_one_line(final_text, NOTE_ANSWER)}"
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
if not _enabled(conn, conversation_id):
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT content FROM agent_memory WHERE conversation_id=?",
|
||||
(conversation_id,),
|
||||
).fetchone()
|
||||
lines = [ln for ln in ((row["content"] if row else "") or "").split("\n") if ln.strip()]
|
||||
lines.append(note)
|
||||
if len(lines) > NOTE_LINES:
|
||||
lines = lines[-NOTE_LINES:]
|
||||
conn.execute(
|
||||
"INSERT INTO agent_memory (conversation_id, content, updated_at) "
|
||||
"VALUES (?, ?, CURRENT_TIMESTAMP) "
|
||||
"ON CONFLICT(conversation_id) DO UPDATE SET "
|
||||
"content=excluded.content, updated_at=CURRENT_TIMESTAMP",
|
||||
(conversation_id, "\n".join(lines)),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 — la mémoire ne casse jamais un run
|
||||
logger.exception("Agent memory save failed for conversation #%s", conversation_id)
|
||||
@@ -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"
|
||||
|
||||
@@ -86,6 +86,34 @@ BUILTIN_TEMPLATES: dict[str, dict] = {
|
||||
_b("bulleted_list"),
|
||||
],
|
||||
},
|
||||
"design_system": {
|
||||
"name": "Design System",
|
||||
"icon": "🎨",
|
||||
"description": "Tokens, composants et grille — canevas de référence UI.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Design System"),
|
||||
_b("callout",
|
||||
"Tokens : couleur, espacement, typographie, rayon, ombres "
|
||||
"(static/css/design-tokens.css).",
|
||||
icon="🎨"),
|
||||
_b("table_of_contents"),
|
||||
_b("heading_2", "Composants"),
|
||||
_b("toggle", "Boutons & actions", expanded=False,
|
||||
children=[_b("paragraph", "Primaire · secondaire · danger — états idle, hover, focus, disabled.")]),
|
||||
_b("toggle", "Cartes & panneaux", expanded=False,
|
||||
children=[_b("paragraph", "Élévation, rayon, bordure, padding, zones de clic.")]),
|
||||
_b("toggle", "Formulaires", expanded=False,
|
||||
children=[_b("paragraph", "Champs, labels, hints, erreurs, focus visible.")]),
|
||||
_b("heading_2", "Grille & espacement"),
|
||||
_b("bulleted_list", "Base 4 px — pas d'espacement hors échelle."),
|
||||
_b("bulleted_list", "Colonnes : 12 / 8 / 4 selon le point de rupture."),
|
||||
_b("heading_2", "Revue UI"),
|
||||
_b("to_do", "Contraste AA vérifié", checked=False),
|
||||
_b("to_do", "États hover / focus / disabled", checked=False),
|
||||
_b("to_do", "Responsive mobile", checked=False),
|
||||
_b("quote", "Une décision de design vaut mieux qu'une justification après coup."),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
|
||||
|
||||
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
|
||||
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
|
||||
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
|
||||
l'URL via ``{secret}``) | ``mcp`` (serveur Model Context Protocol, cache
|
||||
d'outils dans ``tools_json``).
|
||||
|
||||
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
|
||||
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
|
||||
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import oauth_connectors
|
||||
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
|
||||
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp")
|
||||
AUTH_SCHEMES = ("bearer", "bot", "none")
|
||||
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
|
||||
# par l'utilisateur — jamais codé en dur ici).
|
||||
PRESETS = {
|
||||
"discord": {"url": "https://discord.com/api/v10", "auth": "bot",
|
||||
"hint": "Bot Discord Developers → Token"},
|
||||
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
|
||||
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
|
||||
"mcp": {"url": "https://", "auth": "bearer",
|
||||
"hint": "URL du endpoint MCP (streamable HTTP)"},
|
||||
}
|
||||
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
|
||||
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
|
||||
|
||||
|
||||
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
|
||||
|
||||
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
|
||||
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
|
||||
if kind in OAUTH_KINDS:
|
||||
try:
|
||||
oauth_connectors._client(kind) # lève si client_id/secret absents
|
||||
except ValueError as exc:
|
||||
return ("missing", str(exc))
|
||||
tok = oauth_connectors.tokens(kind, user_id)
|
||||
if tok.get("access_token"):
|
||||
scope = (tok.get("scope") or "").split()
|
||||
return ("ok", f"connecté · {len(scope)} scope(s)")
|
||||
return ("missing", "non connecté — lancer la connexion OAuth")
|
||||
if kind == "gitea":
|
||||
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
|
||||
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
|
||||
if kind == "github":
|
||||
if settings.github_token:
|
||||
return ("ok", "PAT configuré (30 req/min)")
|
||||
return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)")
|
||||
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
|
||||
|
||||
|
||||
def _row(r) -> dict:
|
||||
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais)."""
|
||||
try:
|
||||
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
|
||||
except (ValueError, TypeError):
|
||||
tools = []
|
||||
return {
|
||||
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
|
||||
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
|
||||
"status": r["status"], "detail": r["detail"] or "",
|
||||
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
|
||||
"tools_count": len(tools),
|
||||
}
|
||||
|
||||
|
||||
def list_connectors(user_id: int | None = None) -> list[dict]:
|
||||
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
|
||||
out: list[dict] = []
|
||||
for kind in NATIVE_KINDS:
|
||||
status, detail = native_state(kind, user_id)
|
||||
out.append({
|
||||
"id": None, "builtin": True, "kind": kind,
|
||||
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
|
||||
else kind.capitalize()),
|
||||
"url": "", "enabled": True, "status": status, "detail": detail,
|
||||
"has_secret": False, "oauth": kind in OAUTH_KINDS,
|
||||
})
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
|
||||
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
|
||||
).fetchall()
|
||||
return out + [_row(r) for r in rows]
|
||||
|
||||
|
||||
def get(connector_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
r = conn.execute(
|
||||
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
|
||||
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
|
||||
(connector_id,),
|
||||
).fetchone()
|
||||
return _row(r) if r else None
|
||||
|
||||
|
||||
# ── CRUD (personnels) ────────────────────────────────────────────────────────
|
||||
|
||||
def create_connector(name: str, url: str, secret: str = "", *,
|
||||
kind: str = "custom", auth: str = "bearer") -> dict:
|
||||
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
|
||||
from app.services.importers.url_fetch import _validate_url
|
||||
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name est requis")
|
||||
if kind not in CUSTOM_KINDS:
|
||||
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
|
||||
if auth not in AUTH_SCHEMES:
|
||||
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
|
||||
url = (url or "").strip()
|
||||
if "{secret}" in url and not (secret or "").strip():
|
||||
raise ValueError("clé requise : l'URL contient {secret}")
|
||||
url = _validate_url(url) # lève ValueError si hôte interne
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
|
||||
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
|
||||
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
|
||||
)
|
||||
conn.commit()
|
||||
cid = cur.lastrowid
|
||||
return get(cid)
|
||||
|
||||
|
||||
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None:
|
||||
row = get(connector_id)
|
||||
if row is None:
|
||||
return None
|
||||
sets, params = [], []
|
||||
if name is not None:
|
||||
name = str(name).strip()
|
||||
if not name:
|
||||
raise ValueError("name est requis")
|
||||
sets.append("name=?")
|
||||
params.append(name)
|
||||
if enabled is not None:
|
||||
sets.append("enabled=?")
|
||||
params.append(1 if enabled else 0)
|
||||
if secret is not None:
|
||||
sets.append("secret_encrypted=?")
|
||||
params.append(encrypt_secret(str(secret)))
|
||||
if sets:
|
||||
with get_conn() as conn:
|
||||
params.append(connector_id)
|
||||
conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
return get(connector_id)
|
||||
|
||||
|
||||
def delete_connector(connector_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
|
||||
|
||||
def _headers(connector_id: int | None = None) -> dict:
|
||||
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
|
||||
|
||||
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
|
||||
"""
|
||||
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
|
||||
secret, auth = "", "bearer"
|
||||
if connector_id:
|
||||
with get_conn() as conn:
|
||||
r = conn.execute(
|
||||
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
|
||||
(connector_id,),
|
||||
).fetchone()
|
||||
if r:
|
||||
secret = decrypt_secret(r["secret_encrypted"] or "")
|
||||
auth = r["auth"] or "bearer"
|
||||
if secret and auth == "bot":
|
||||
headers["Authorization"] = f"Bot {secret}"
|
||||
elif secret and auth != "none":
|
||||
headers["Authorization"] = f"Bearer {secret}"
|
||||
headers["X-Api-Key"] = secret
|
||||
return headers
|
||||
|
||||
|
||||
def _with_secret(url: str, connector_id: int) -> str:
|
||||
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
|
||||
if "{secret}" not in url:
|
||||
return url
|
||||
with get_conn() as conn:
|
||||
r = conn.execute(
|
||||
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
|
||||
(connector_id,),
|
||||
).fetchone()
|
||||
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
|
||||
if not secret:
|
||||
raise ValueError("clé manquante pour une URL contenant {secret}")
|
||||
return url.replace("{secret}", secret)
|
||||
|
||||
|
||||
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
|
||||
"""GET gardé SSRF (validation + re-vérification après redirection).
|
||||
|
||||
Point d'injection des tests : on monkeypatche ``connectors._get``.
|
||||
"""
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.url_fetch import _is_public_host, _validate_url
|
||||
|
||||
safe = _validate_url(url)
|
||||
async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client:
|
||||
resp = await client.get(safe)
|
||||
if resp.url.host and not _is_public_host(resp.url.host):
|
||||
raise ValueError("Redirection vers un hôte non autorisé")
|
||||
return resp.status_code, (resp.text or "")[:FETCH_LIMIT]
|
||||
|
||||
|
||||
def _resolve(connector: str) -> tuple[str, str | int]:
|
||||
"""« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind)."""
|
||||
token = str(connector or "").strip()
|
||||
if not token:
|
||||
raise ValueError("connector est requis")
|
||||
if token.isdigit():
|
||||
row = get(int(token))
|
||||
if row is None:
|
||||
raise ValueError(f"Connecteur inconnu: {token}")
|
||||
return (row["kind"], row["id"])
|
||||
low = token.lower()
|
||||
if low in NATIVE_KINDS:
|
||||
return (low, low)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return (get(row["id"])["kind"], row["id"])
|
||||
raise ValueError(f"Connecteur inconnu: {token}")
|
||||
|
||||
|
||||
async def probe(connector: str, user_id: int | None = None) -> dict:
|
||||
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
|
||||
kind, ref = _resolve(connector)
|
||||
try:
|
||||
if kind == "mcp":
|
||||
from app.services import mcp_client
|
||||
tools = await mcp_client.initialize_and_list(int(ref))
|
||||
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
|
||||
elif kind in OAUTH_KINDS:
|
||||
res = await oauth_connectors.api_get(kind, user_id,
|
||||
oauth_connectors.PROVIDERS[kind]["probe_path"])
|
||||
status, detail = res["status"], res["text"][:200]
|
||||
elif kind == "gitea":
|
||||
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
|
||||
{"Authorization": f"token {settings.gitea_token}"})
|
||||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||
elif kind == "github":
|
||||
code, _ = await _get("https://api.github.com/rate_limit", _gh_headers())
|
||||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||
elif kind == "web":
|
||||
status, detail = "ok", native_state("web")[1]
|
||||
else:
|
||||
row = get(int(ref))
|
||||
url = _with_secret(row["url"], int(ref))
|
||||
code, _ = await _get(url, _headers(int(ref)))
|
||||
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
|
||||
logger.info("Connector probe failed (%s): %s", connector, exc)
|
||||
status, detail = "error", str(exc)[:200]
|
||||
if kind not in NATIVE_KINDS:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
|
||||
(status, detail, int(ref)),
|
||||
)
|
||||
conn.commit()
|
||||
return {"connector": connector, "status": status, "detail": detail}
|
||||
|
||||
|
||||
def _gh_headers() -> dict:
|
||||
headers = {"User-Agent": "FlowDeck-Connectors/1.0",
|
||||
"Accept": "application/vnd.github+json"}
|
||||
if settings.github_token:
|
||||
headers["Authorization"] = f"Bearer {settings.github_token}"
|
||||
return headers
|
||||
|
||||
|
||||
async def connector_fetch(connector: str, path: str = "", query: str = "",
|
||||
user_id: int | None = None) -> dict:
|
||||
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
|
||||
kind, ref = _resolve(connector)
|
||||
path = (path or "").strip()
|
||||
if kind in OAUTH_KINDS:
|
||||
return await oauth_connectors.api_get(kind, user_id, path)
|
||||
if kind == "mcp":
|
||||
from app.services import mcp_client
|
||||
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
|
||||
if kind == "gitea":
|
||||
base = settings.gitea_url.rstrip("/")
|
||||
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
|
||||
code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"})
|
||||
elif kind == "github":
|
||||
url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit"
|
||||
code, text = await _get(url, _gh_headers())
|
||||
elif kind == "web":
|
||||
from app.services.web_search import search_web
|
||||
if not (query or "").strip():
|
||||
return {"status": "error", "text": "query est requis pour le connecteur web"}
|
||||
results, provider = await search_web(query.strip(), 5)
|
||||
text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}"
|
||||
for r in results) or "Aucun résultat."
|
||||
return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]}
|
||||
else:
|
||||
row = get(int(ref))
|
||||
if row is None:
|
||||
return {"status": "error", "text": "Connecteur supprimé"}
|
||||
if not row["enabled"]:
|
||||
return {"status": "error", "text": "Connecteur désactivé"}
|
||||
url = _with_secret(row["url"], int(ref)).rstrip("/")
|
||||
if path:
|
||||
url += "/" + path.lstrip("/")
|
||||
code, text = await _get(url, _headers(int(ref)))
|
||||
if code >= 400:
|
||||
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
||||
return {"status": "ok", "text": text[:FETCH_LIMIT]}
|
||||
@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
|
||||
PROVIDERS = {
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
||||
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
|
||||
# mistral-small est servi par tous les plans d'abonnement ;
|
||||
# mistral-large → 403 tier_not_allowed sur les plans basiques.
|
||||
"mistral": ("https://api.mistral.ai/v1", "mistral-small-latest"),
|
||||
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
|
||||
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
|
||||
@@ -94,8 +96,11 @@ PROVIDER_LABELS: dict[str, str] = {
|
||||
PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
||||
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
||||
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
|
||||
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
|
||||
# Ordre = ordre de sélection du test de connexion : les modèles servis par
|
||||
# tous les plans d'abord (large/pixtral-large = 403 tier_not_allowed en plan
|
||||
# basique), les modèles à plan élevé en fin de liste.
|
||||
"mistral": ["mistral-small-latest", "mistral-medium-latest", "open-mistral-nemo",
|
||||
"codestral-latest", "mistral-large-latest", "pixtral-large-latest"],
|
||||
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
|
||||
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
|
||||
|
||||
@@ -22,7 +22,10 @@ from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
|
||||
# before being exposed as "usable models". NVIDIA exposes all of its catalog
|
||||
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
|
||||
# chat completions — the exact failure the user hit.
|
||||
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
|
||||
# Mistral : le /models liste des modèles hors du plan d'abonnement du compte
|
||||
# (403 « tier_not_allowed » ex. mistral-large sur plan basique) — la sonde
|
||||
# chat ne garde que ceux réellement servis par la CLÉ de l'utilisateur.
|
||||
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia", "mistral"})
|
||||
|
||||
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
|
||||
_NON_CHAT_MARKERS = (
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Client MCP (Model Context Protocol) — streamable HTTP, v7.57.0.
|
||||
|
||||
JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list``
|
||||
→ ``tools/call``. Les outils sont **cachés** dans
|
||||
``agent_connectors.tools_json`` (kind ``mcp``) puis exposés au LLM par
|
||||
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
|
||||
sans état en mémoire.
|
||||
|
||||
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
|
||||
réponse JSON directe, soit la première ligne ``data:`` d'une
|
||||
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
|
||||
répondent en JSON chez la grande majorité des serveurs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.connectors import _headers, _with_secret, get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Version la plus répandue côté serveurs (un serveur plus récent rétrograde).
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
|
||||
|
||||
|
||||
def tool_name(server_name: str, tool_name: str) -> str:
|
||||
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
|
||||
def slug(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_")
|
||||
joined = f"mcp_{slug(server_name)}_{slug(tool_name)}"
|
||||
return re.sub(r"_{2,}", "_", joined)
|
||||
|
||||
|
||||
def parse_body(content_type: str, body: str, status: int) -> dict:
|
||||
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
|
||||
text = body or ""
|
||||
if "text/event-stream" in (content_type or ""):
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
text = line[5:].strip()
|
||||
break
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
|
||||
if isinstance(data, dict) and data.get("error"):
|
||||
err = data["error"] if isinstance(data["error"], dict) else {}
|
||||
raise ValueError(f"MCP {err.get('code', '?')} : {err.get('message', 'erreur')}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
|
||||
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.url_fetch import _validate_url
|
||||
|
||||
safe = _validate_url(url)
|
||||
hdrs = {"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
**(headers or {})}
|
||||
async with shared_client(timeout=15, headers=hdrs) as client:
|
||||
resp = await client.post(safe, json=payload)
|
||||
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
|
||||
resp.status_code)
|
||||
|
||||
|
||||
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
|
||||
"""Notification JSON-RPC (202 sans corps) — les erreurs sont ignorées."""
|
||||
try:
|
||||
await _rpc(url, payload, headers)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("MCP notification ignorée: %s", exc)
|
||||
|
||||
|
||||
def _server(connector_id: int) -> dict:
|
||||
row = get(connector_id)
|
||||
if row is None:
|
||||
raise ValueError("Serveur MCP introuvable")
|
||||
if row["kind"] != "mcp":
|
||||
raise ValueError("Ce connecteur n'est pas un serveur MCP")
|
||||
if not row["enabled"]:
|
||||
raise ValueError("Serveur MCP désactivé")
|
||||
return {"id": row["id"], "name": row["name"], "url": row["url"],
|
||||
"headers": _headers(connector_id)}
|
||||
|
||||
|
||||
def _normalize(server: dict, raw: list) -> list[dict]:
|
||||
out = []
|
||||
for t in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(t, dict) or not t.get("name"):
|
||||
continue
|
||||
params = t.get("inputSchema")
|
||||
if not isinstance(params, dict):
|
||||
params = {"type": "object", "properties": {}}
|
||||
out.append({
|
||||
"name": tool_name(server["name"], t["name"]),
|
||||
"original": str(t["name"]),
|
||||
"description": str(t.get("description") or "Outil MCP"),
|
||||
"parameters": params,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _save_tools(connector_id: int, tools: list[dict]) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE agent_connectors SET tools_json=? WHERE id=?",
|
||||
(json.dumps(tools), connector_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def initialize_and_list(connector_id: int) -> list[dict]:
|
||||
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
|
||||
srv = _server(connector_id)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
await _rpc(url, {
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
|
||||
"clientInfo": CLIENT_INFO},
|
||||
}, srv["headers"])
|
||||
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
srv["headers"])
|
||||
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||
"params": {}}, srv["headers"])
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||
tools = _normalize(srv, result.get("tools") or [])
|
||||
_save_tools(connector_id, tools)
|
||||
return tools
|
||||
|
||||
|
||||
def cached_tools() -> list[dict]:
|
||||
"""Outils MCP des serveurs **activés** (cache en base) — lu par ToolRegistry."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, tools_json FROM agent_connectors WHERE kind='mcp' AND enabled=1"
|
||||
).fetchall()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
tools = json.loads(r["tools_json"] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
tools = []
|
||||
for t in tools if isinstance(tools, list) else []:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
out.append({**t, "connector_id": r["id"]})
|
||||
return out
|
||||
|
||||
|
||||
def tools_text(connector_id: int) -> str:
|
||||
"""Liste lisible des outils cachés (utilisée par ``connector_fetch``)."""
|
||||
tools = [t for t in cached_tools() if t["connector_id"] == connector_id]
|
||||
if not tools:
|
||||
return "Aucun outil en cache — lancez « Tester le connecteur » (tools/list)."
|
||||
return "\n".join(f"- {t['name']} : {t.get('description', '')}" for t in tools)
|
||||
|
||||
|
||||
def _content_text(result: dict) -> str:
|
||||
parts = []
|
||||
for item in result.get("content") or []:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
parts.append(str(item.get("text") or ""))
|
||||
else:
|
||||
parts.append(json.dumps(item, ensure_ascii=False)[:2000])
|
||||
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
|
||||
|
||||
|
||||
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
|
||||
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
|
||||
srv = _server(connector_id)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
data = await _rpc(url, {
|
||||
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||
"params": {"name": tool, "arguments": arguments or {}},
|
||||
}, srv["headers"])
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||
text = _content_text(result)[:20000]
|
||||
return {"status": "error" if result.get("isError") else "ok", "text": text}
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Connecteurs OAuth Google / Microsoft 365 (v7.56.0 — phase 6).
|
||||
|
||||
Flow : ``begin()`` (URL d'autorisation PKCE S256 + state) → callback
|
||||
``complete()`` (échange du code) → tokens chiffrés Fernet → ``api_get()`` avec
|
||||
refresh automatique.
|
||||
|
||||
Réutilise : `_encrypt_tokens` / `_decrypt_tokens` (`calendar_sync`, déjà Fernet)
|
||||
et `shared_client` (A42). Les scopes sont **figés en lecture seule**.
|
||||
|
||||
ponytail : 2 providers décrits par 1 dict (pas de classe par provider) ; les
|
||||
scopes au choix et un écran de connexion dédié arriveront si le besoin se
|
||||
présente — l'état vit dans le catalogue du menu +.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.calendar_sync import _decrypt_tokens, _encrypt_tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDERS: dict[str, dict] = {
|
||||
"google": {
|
||||
"name": "Google (Drive · Gmail · Calendar)",
|
||||
"authorize": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token": "https://oauth2.googleapis.com/token",
|
||||
"api": "https://www.googleapis.com",
|
||||
"scopes": [
|
||||
"openid", "email", "profile",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
],
|
||||
"extra": {"access_type": "offline", "include_granted_scopes": "true"},
|
||||
"probe_path": "/oauth2/v3/userinfo",
|
||||
},
|
||||
"ms365": {
|
||||
"name": "Microsoft 365 (Outlook · OneDrive)",
|
||||
"authorize": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
"token": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
"api": "https://graph.microsoft.com/v1.0",
|
||||
"scopes": [
|
||||
"openid", "email", "profile", "offline_access",
|
||||
"User.Read", "Files.Read", "Mail.Read", "Calendars.Read",
|
||||
"ChannelMessage.Read.All", # Teams (phase 7) — consentement admin
|
||||
],
|
||||
"extra": {"response_mode": "query", "prompt": "consent"},
|
||||
"probe_path": "/me",
|
||||
},
|
||||
}
|
||||
OAUTH_KINDS = tuple(PROVIDERS)
|
||||
# l'access token est renouvelé 60 s avant expiration
|
||||
_REFRESH_SKEW = 60
|
||||
|
||||
|
||||
# ── Config client ───────────────────────────────────────────────────────────
|
||||
|
||||
def _client(kind: str) -> tuple[str, str, str]:
|
||||
"""(client_id, client_secret, redirect_uri) — lève si non configuré."""
|
||||
if kind not in PROVIDERS:
|
||||
raise ValueError(f"Connecteur OAuth inconnu: {kind}")
|
||||
if kind == "google":
|
||||
cid, secret = settings.google_client_id, settings.google_client_secret
|
||||
else:
|
||||
cid, secret = settings.ms_client_id, settings.ms_client_secret
|
||||
if not cid or not secret:
|
||||
raise ValueError(
|
||||
f"Connecteur {kind} non configuré (GOOGLE_CLIENT_ID/SECRET ou MS_CLIENT_ID/SECRET)"
|
||||
)
|
||||
redirect = f"{settings.app_base_url.rstrip('/')}/api/agent/connectors/oauth/{kind}/callback"
|
||||
return cid, secret, redirect
|
||||
|
||||
|
||||
def callback_path(kind: str) -> str:
|
||||
return f"/api/agent/connectors/oauth/{kind}/callback"
|
||||
|
||||
|
||||
def _pkce_pair() -> tuple[str, str]:
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
# ── Flow ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def begin(kind: str) -> dict:
|
||||
"""URL d'autorisation + state + code_verifier (le route met les cookies)."""
|
||||
cid, _secret, redirect = _client(kind)
|
||||
verifier, challenge = _pkce_pair()
|
||||
state = secrets.token_urlsafe(24)
|
||||
params = {
|
||||
"client_id": cid,
|
||||
"redirect_uri": redirect,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(PROVIDERS[kind]["scopes"]),
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
params.update(PROVIDERS[kind].get("extra", {}))
|
||||
return {
|
||||
"url": f"{PROVIDERS[kind]['authorize']}?{urlencode(params)}",
|
||||
"state": state,
|
||||
"verifier": verifier,
|
||||
"redirect_uri": redirect,
|
||||
}
|
||||
|
||||
|
||||
def _normalize(data: dict) -> dict:
|
||||
expires_in = int(data.get("expires_in") or 3600)
|
||||
return {
|
||||
"access_token": str(data.get("access_token") or ""),
|
||||
"refresh_token": str(data.get("refresh_token") or ""),
|
||||
"scope": str(data.get("scope") or ""),
|
||||
"expires_at": int(time.time()) + expires_in,
|
||||
}
|
||||
|
||||
|
||||
async def complete(kind: str, code: str, verifier: str) -> dict:
|
||||
"""Échange le code contre des tokens (aucun réseau ici hors `_post_form`)."""
|
||||
cid, secret, redirect = _client(kind)
|
||||
data = await _post_form(PROVIDERS[kind]["token"], {
|
||||
"client_id": cid,
|
||||
"client_secret": secret,
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect,
|
||||
"code_verifier": verifier,
|
||||
})
|
||||
tokens = _normalize(data)
|
||||
if not tokens["access_token"]:
|
||||
raise ValueError(str(data.get("error_description") or data.get("error") or "échec de l'échange du code"))
|
||||
return tokens
|
||||
|
||||
|
||||
# ── Stockage (chiffré Fernet, comme calendar_sync) ──────────────────────────
|
||||
|
||||
def save(kind: str, user_id: int, tokens: dict) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO connector_tokens (kind, user_id, tokens_enc, updated_at) "
|
||||
"VALUES (?,?,?,CURRENT_TIMESTAMP) "
|
||||
"ON CONFLICT(kind, user_id) DO UPDATE SET "
|
||||
"tokens_enc=excluded.tokens_enc, updated_at=CURRENT_TIMESTAMP",
|
||||
(kind, user_id, _encrypt_tokens(tokens)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def tokens(kind: str, user_id: int | None) -> dict:
|
||||
if not user_id:
|
||||
return {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT tokens_enc FROM connector_tokens WHERE kind=? AND user_id=?",
|
||||
(kind, user_id),
|
||||
).fetchone()
|
||||
return _decrypt_tokens(row["tokens_enc"]) if row else {}
|
||||
|
||||
|
||||
def clear(kind: str, user_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM connector_tokens WHERE kind=? AND user_id=?", (kind, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def is_connected(kind: str, user_id: int | None) -> bool:
|
||||
return bool(tokens(kind, user_id).get("access_token"))
|
||||
|
||||
|
||||
# ── Réseau ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _post_form(url: str, data: dict) -> dict:
|
||||
"""POST x-www-form-urlencoded vers le token endpoint → dict JSON.
|
||||
|
||||
Point d'injection des tests (on monkeypatche ``oauth_connectors._post_form``).
|
||||
"""
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
async with shared_client(timeout=15, headers={"Accept": "application/json"}) as client:
|
||||
resp = await client.post(url, data=data)
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _api_get(url: str, headers: dict) -> tuple[int, str]:
|
||||
"""GET d'une API fournisseur — 2ᵉ point d'injection des tests."""
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
async with shared_client(timeout=15, headers=headers) as client:
|
||||
resp = await client.get(url)
|
||||
return resp.status_code, (resp.text or "")[:20000]
|
||||
|
||||
|
||||
async def access_token(kind: str, user_id: int | None) -> str:
|
||||
"""Access token valide, rafraîchi (refresh_token) si nécessaire."""
|
||||
tok = tokens(kind, user_id)
|
||||
if not tok.get("access_token"):
|
||||
return ""
|
||||
if tok.get("expires_at", 0) > time.time() + _REFRESH_SKEW:
|
||||
return tok["access_token"]
|
||||
if not tok.get("refresh_token"):
|
||||
return "" # expiré sans refresh → à reconnexion
|
||||
try:
|
||||
cid, secret, redirect = _client(kind)
|
||||
data = await _post_form(PROVIDERS[kind]["token"], {
|
||||
"client_id": cid,
|
||||
"client_secret": secret,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": tok["refresh_token"],
|
||||
"redirect_uri": redirect,
|
||||
})
|
||||
fresh = _normalize(data)
|
||||
if not fresh["access_token"]:
|
||||
raise ValueError(data.get("error_description") or "refresh refusé")
|
||||
# Google ne renvoie parfois pas de nouveau refresh_token : on garde l'ancien
|
||||
fresh["refresh_token"] = fresh["refresh_token"] or tok["refresh_token"]
|
||||
save(kind, int(user_id), fresh)
|
||||
return fresh["access_token"]
|
||||
except Exception as exc: # noqa: BLE001 — jamais d'exception qui casse un run
|
||||
logger.warning("OAuth refresh failed (%s): %s", kind, exc)
|
||||
return ""
|
||||
|
||||
|
||||
async def api_get(kind: str, user_id: int | None, path: str = "") -> dict:
|
||||
"""GET sur l'API du fournisseur avec le token de l'utilisateur."""
|
||||
if kind not in PROVIDERS:
|
||||
return {"status": "error", "text": f"Connecteur OAuth inconnu: {kind}"}
|
||||
access = await access_token(kind, user_id)
|
||||
if not access:
|
||||
return {"status": "error", "text": f"{kind} non connecté (lancez la connexion OAuth)"}
|
||||
if "://" in (path or ""):
|
||||
return {"status": "error", "text": "path doit être relatif (pas d'URL absolue)"}
|
||||
# base fixe + chemin : pas d'urljoin (une URL absolue ne peut pas dévier
|
||||
# l'hôte), puis validation SSRF par principe.
|
||||
url = PROVIDERS[kind]["api"].rstrip("/") + "/" + (path or "").lstrip("/")
|
||||
from app.services.importers.url_fetch import _validate_url
|
||||
try:
|
||||
_validate_url(url)
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "text": str(exc)}
|
||||
code, text = await _api_get(url, {"Authorization": f"Bearer {access}"})
|
||||
if code >= 400:
|
||||
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
||||
return {"status": "ok", "text": text[:20000]}
|
||||
@@ -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
|
||||
@@ -1072,6 +1072,79 @@ class SearchCode(Tool):
|
||||
|
||||
# ══════════════════════════ Registry ══════════════════════════
|
||||
|
||||
class ConnectorFetch(Tool):
|
||||
name = "connector_fetch"
|
||||
description = (
|
||||
"Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), "
|
||||
"recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). "
|
||||
"`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; "
|
||||
"`query` sert au connecteur web. Les hôtes privés sont refusés."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connector": {"type": "string",
|
||||
"description": "id, nom ou kind (gitea / github / web)"},
|
||||
"path": {"type": "string", "description": "chemin relatif (API natives)"},
|
||||
"query": {"type": "string", "description": "terme de recherche (web)"},
|
||||
},
|
||||
"required": ["connector"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services import connectors
|
||||
|
||||
try:
|
||||
res = await connectors.connector_fetch(
|
||||
str(args.get("connector") or ""),
|
||||
str(args.get("path") or ""),
|
||||
str(args.get("query") or ""),
|
||||
user_id=user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
|
||||
logger.warning("connector_fetch failed: %s", exc)
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Connecteur indisponible: {exc}")
|
||||
if res.get("status") != "ok":
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=(res.get("text") or "erreur")[:500])
|
||||
text = res.get("text") or ""
|
||||
return ToolResult(status="success", tool=self.name, target_type="connector",
|
||||
message=text[:800], data={"text": text})
|
||||
|
||||
|
||||
class McpTool(Tool):
|
||||
"""Outil MCP dynamique — instance construite à la volée depuis le cache
|
||||
en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES."""
|
||||
|
||||
def __init__(self, entry: dict):
|
||||
self.name = entry["name"]
|
||||
self.description = entry.get("description") or "Outil MCP"
|
||||
self.parameters = entry.get("parameters") or {"type": "object", "properties": {}}
|
||||
self.entry = entry
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services import mcp_client
|
||||
|
||||
try:
|
||||
res = await mcp_client.call_tool(self.entry["connector_id"],
|
||||
self.entry.get("original") or "", args)
|
||||
except ValueError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
|
||||
logger.warning("MCP tool failed (%s): %s", self.name, exc)
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"MCP indisponible: {exc}")
|
||||
if res.get("status") != "ok":
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=(res.get("text") or "erreur MCP")[:500])
|
||||
text = res.get("text") or ""
|
||||
return ToolResult(status="success", tool=self.name, target_type="mcp",
|
||||
message=text[:800], data={"text": text})
|
||||
|
||||
|
||||
TOOL_CLASSES = [
|
||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||
@@ -1080,7 +1153,7 @@ TOOL_CLASSES = [
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
DeleteDocument,
|
||||
WebSearch, FetchUrl, SearchCode,
|
||||
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
|
||||
]
|
||||
|
||||
|
||||
@@ -1090,24 +1163,47 @@ class ToolRegistry:
|
||||
def __init__(self):
|
||||
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
|
||||
|
||||
def _all(self) -> dict[str, Tool]:
|
||||
"""Outils statiques + **outils MCP dynamiques** (cache en base).
|
||||
|
||||
Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base,
|
||||
donc « Tester le connecteur » suffit à rendre un outil disponible.
|
||||
"""
|
||||
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("dynamic tools load failed")
|
||||
return tools
|
||||
|
||||
def list(self, scope: dict | None = None) -> list[str]:
|
||||
"""Tool names allowed by an agent scope (default: all)."""
|
||||
tools = self._all()
|
||||
allowed = (scope or {}).get("tools")
|
||||
if allowed is None:
|
||||
return list(self.tools.keys())
|
||||
return [n for n in self.tools if n in allowed]
|
||||
return list(tools.keys())
|
||||
return [n for n in tools if n in allowed]
|
||||
|
||||
def schema(self, scope: dict | None = None) -> list[dict]:
|
||||
"""Function-calling schema for the LLM, filtered by scope."""
|
||||
tools = self._all()
|
||||
return [
|
||||
{"name": self.tools[n].name,
|
||||
"description": self.tools[n].description,
|
||||
"parameters": self.tools[n].parameters}
|
||||
{"name": tools[n].name,
|
||||
"description": tools[n].description,
|
||||
"parameters": tools[n].parameters}
|
||||
for n in self.list(scope)
|
||||
]
|
||||
|
||||
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
|
||||
impl = self.tools.get(tool)
|
||||
impl = self._all().get(tool)
|
||||
if not impl:
|
||||
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
|
||||
return await impl.execute(args, user_id=user_id)
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
{% if not hide_hamburger %}
|
||||
<button class="hamburger-btn"
|
||||
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
@click="{{ hamburger_click|default('fdHamburgerClick()') }}"
|
||||
title="Toggle sidebar">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
|
||||
@@ -444,6 +444,25 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-plus-form" x-show="plusSection==='connector-new'" x-cloak>
|
||||
<label for="fd-plus-cn-kind">Type</label>
|
||||
<select id="fd-plus-cn-kind" x-model="connectorForm.kind" @change="connectorPreset()">
|
||||
<option value="custom">Connecteur personnalisé (HTTP + clé)</option>
|
||||
<option value="discord">Discord (bot)</option>
|
||||
<option value="telegram">Telegram (bot)</option>
|
||||
<option value="mcp">Serveur MCP (streamable HTTP)</option>
|
||||
</select>
|
||||
<label for="fd-plus-cn-name">Nom</label>
|
||||
<input id="fd-plus-cn-name" type="text" x-model="connectorForm.name" placeholder="ex. Base de connaissances" @keydown.enter.prevent="connectorCreate()">
|
||||
<label for="fd-plus-cn-url" x-text="connectorForm.hint || 'URL publique (hôtes privés refusés)'"></label>
|
||||
<input id="fd-plus-cn-url" type="text" x-model="connectorForm.url" placeholder="https://api.exemple.com">
|
||||
<label for="fd-plus-cn-secret">Clé / jeton — chiffrée, jamais renvoyée</label>
|
||||
<input id="fd-plus-cn-secret" type="password" x-model="connectorForm.secret" placeholder="sk-…" autocomplete="off">
|
||||
<div class="fd-proposal-bar">
|
||||
<button class="fd-proposal-btn primary" type="button" @click="connectorCreate()">✓ Ajouter</button>
|
||||
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" accept="application/json,.json" x-ref="skillImport" class="fd-plus-file" aria-label="Importer un skill JSON" @change="importSkillFile($event)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/settings.css?v={{ asset_version }}">
|
||||
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
|
||||
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
|
||||
htmx lors d'une navigation partielle : la page revenait alors sans
|
||||
@@ -1944,6 +1945,17 @@
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
},
|
||||
|
||||
// Hamburger: mobile opens the drawer (assignment expressions are illegal
|
||||
// in the Alpine CSP build, hence this method), desktop toggles collapse.
|
||||
fdHamburgerClick() {
|
||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
this.mobileSidebarOpen = true;
|
||||
this.sidebarCollapsed = false;
|
||||
} else {
|
||||
this.toggleSidebar();
|
||||
}
|
||||
},
|
||||
|
||||
closeSidebar() {
|
||||
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
|
||||
this.sidebarCollapsed = true;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Help{% endblock %}
|
||||
{% block title_prefix %}Help{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("help-circle",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "help-circle" %}
|
||||
{% set page_title = "Help" %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Same panel skeleton as settings (shared /static/css/settings.css) #}
|
||||
<style>
|
||||
.help-body{margin-bottom:24px;max-width:760px;}
|
||||
.help-body p{font-size:14px;color:var(--text-secondary);line-height:1.65;margin:0 0 10px;}
|
||||
.help-body ul{margin:0 0 14px;padding-left:18px;}
|
||||
.help-body li{font-size:14px;color:var(--text-secondary);line-height:1.6;margin-bottom:4px;}
|
||||
.help-body code{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:12.5px;}
|
||||
.help-body h3{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin:22px 0 8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
||||
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
||||
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
||||
.help-shortcut-row:hover{background:var(--bg-hover);}
|
||||
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
||||
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
||||
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="helpInit()" style="background:none;backdrop-filter:none;position:static;z-index:1;padding:0;">
|
||||
<div class="settings-panel" style="width:100%;max-width:1050px;height:calc(100vh - 120px);margin:0 auto;" @keydown.escape="navOpen = false">
|
||||
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<button class="settings-close" @click="close()" title="Close" aria-label="Close help">×</button>
|
||||
|
||||
<!-- Mobile drawer backdrop -->
|
||||
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
|
||||
|
||||
<!-- Left navigation -->
|
||||
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
|
||||
<div class="settings-nav-header">Basics</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='start' }" @click="go('start')">🚀 Getting Started</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='editor' }" @click="go('editor')">📝 Pages & Editor</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='databases' }" @click="go('databases')">🗄️ Databases & Views</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='tasks' }" @click="go('tasks')">✅ Tasks & Dependencies</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='workspaces' }" @click="go('workspaces')">📁 Workspaces & Forges</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='collab' }" @click="go('collab')">👥 Collaboration</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='nav' }" @click="go('nav')">📚 Library, Trash & Search</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='agent' }" @click="go('agent')">🤖 AI Agent & Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='integrations' }" @click="go('integrations')">🔌 Integrations & API</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='pwa' }" @click="go('pwa')">📶 Offline & PWA</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='auth' }" @click="go('auth')">🔐 Accounts & SSO</div>
|
||||
<div class="settings-nav-header">Reference</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='shortcuts' }" @click="go('shortcuts')">⌨️ Keyboard Shortcuts</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='tips' }" @click="go('tips')">💡 Tips</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="settings-content">
|
||||
|
||||
<div class="help-body" x-show="section==='start'">
|
||||
<h2>Getting Started</h2>
|
||||
<p>FlowDeck is your private, self-hosted Notion-style workspace — pages, databases, and Git-forge (Gitea / GitHub) integration in one app.</p>
|
||||
<h3>First steps</h3>
|
||||
<ul>
|
||||
<li>Open <b>Workspaces</b> (<code>/workspaces</code>) to pick or create a workspace — local or connected to a forge.</li>
|
||||
<li>Click <b>+ New page</b> in the sidebar to start writing, or press <span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span>.</li>
|
||||
<li>Use the <b>Agent & IA</b> section in Settings to plug an LLM provider (OpenAI, Ollama, DeepInfra…) if you want AI features.</li>
|
||||
</ul>
|
||||
<h3>The sidebar</h3>
|
||||
<ul>
|
||||
<li>The workspace header (top-left) switches workspaces and opens your account menu.</li>
|
||||
<li>Tabs <b>Home / Chat / Meetings / Inbox</b> swap the sidebar content; pages are a drag-and-drop tree.</li>
|
||||
<li>On mobile the sidebar is a drawer: open it with the hamburger button, close it by tapping outside or swiping left.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='editor'">
|
||||
<h2>Pages & Editor</h2>
|
||||
<p>Notion-style block editor: every line is a block you can drag, convert, and nest.</p>
|
||||
<h3>Blocks</h3>
|
||||
<ul>
|
||||
<li>Type <code>/</code> for the slash menu — headings, callouts, toggles, code, math (KaTeX), tables, images, embeds, ToC…</li>
|
||||
<li>Markdown shortcuts as you type: <code># </code> heading, <code>- </code> bullet, <code>1. </code> numbered, <code>> </code> quote, <code>[] </code> todo, <code>``` </code> code.</li>
|
||||
<li>Drag the handle left of a block to move or nest it; <span class="help-kbd">Tab</span>/<span class="help-kbd">Shift+Tab</span> indent/outdent.</li>
|
||||
<li>Inline formatting: bold, italic, code, links, colors via the selection toolbar.</li>
|
||||
</ul>
|
||||
<h3>Page features</h3>
|
||||
<ul>
|
||||
<li>Cover image and icon on every page (click the top of the page).</li>
|
||||
<li>Inline databases: add a table/board/calendar collection directly in a page.</li>
|
||||
<li>Comments (threaded), page history with snapshots, backlinks.</li>
|
||||
<li>Right-click any sidebar item for context menu: duplicate, rename, move, delete.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='databases'">
|
||||
<h2>Databases & Views</h2>
|
||||
<p>Collections are databases with a JSON schema, independent of Gitea. <b>21 property types</b>: title, text, number, select, multi-select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula, and auto-properties.</p>
|
||||
<h3>Views</h3>
|
||||
<ul>
|
||||
<li><b>Table</b> — sortable columns, inline editing.</li>
|
||||
<li><b>Board (Kanban)</b> — group by select/status, drag & drop between columns.</li>
|
||||
<li><b>Calendar</b> — month grid by date property.</li>
|
||||
<li><b>Gallery</b> — visual cards, configurable card size.</li>
|
||||
<li><b>List</b> — compact rows with preview.</li>
|
||||
<li><b>Timeline</b> — Gantt bars from date ranges.</li>
|
||||
<li><b>Status Overview / Team Load</b> — donut + stacked-bar dashboards.</li>
|
||||
</ul>
|
||||
<h3>Relations, rollups, formulas</h3>
|
||||
<ul>
|
||||
<li>Relations are bidirectional: link rows across collections.</li>
|
||||
<li>Rollups aggregate related rows (12 aggregations: sum, count, average, min, max…).</li>
|
||||
<li>Formulas support 19 functions over row properties.</li>
|
||||
<li>Filters, sorts and grouping apply per-view.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='tasks'">
|
||||
<h2>Tasks & Dependencies</h2>
|
||||
<ul>
|
||||
<li>Todo blocks anywhere in pages become checkboxes; the <b>My Tasks</b> page aggregates every task in every collection you can access.</li>
|
||||
<li>My Tasks views: <b>All / Today / Overdue / Next 7 days</b>.</li>
|
||||
<li><b>Sub-items</b>: unlimited hierarchy — a parent row is Done when all children are Done (status aggregate).</li>
|
||||
<li><b>Dependencies</b>: Blocking / Blocked by between rows; transitioning a blocked task is refused until its blocker is done.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='workspaces'">
|
||||
<h2>Workspaces & Forges</h2>
|
||||
<p>Organize work into separate workspaces, each with its own page tree, collections and members.</p>
|
||||
<ul>
|
||||
<li><span class="help-badge local">Local</span> Pages and files stored on your server (SQLite under <code>/data</code>).</li>
|
||||
<li><span class="help-badge gitea">Gitea</span> Connect your Gitea account (Settings → Integrations): repos appear as workspaces, browse the file tree in the sidebar, create/edit files with commit messages, sync labels as tags.</li>
|
||||
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations for repo browsing.</li>
|
||||
<li>Gitea legacy boards are auto-adapted into FlowDeck collections (GiteaBoardCompat).</li>
|
||||
<li>The <b>Private</b> section appears when a remote workspace is active — those pages stay local.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='collab'">
|
||||
<h2>Collaboration</h2>
|
||||
<ul>
|
||||
<li><b>Roles</b>: workspace members are admin / editor / commenter / viewer.</li>
|
||||
<li><b>Sharing</b>: the Share button (top-right of a page) — share with specific users or publish a read-only public link (<code>/p/your-slug</code>).</li>
|
||||
<li><b>Teamspaces</b> (wiki): shared knowledge spaces with their own pages.</li>
|
||||
<li><b>Meetings</b>: meeting notes with an action-items panel.</li>
|
||||
<li><b>Sprints</b>: time-box collections of tasks, with burndown-ready statuses.</li>
|
||||
<li><b>Notifications</b>: bell in the topbar for mentions, assignments, comments.</li>
|
||||
<li>Live editing: changes propagate to other open tabs (real-time sync).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='nav'">
|
||||
<h2>Library, Trash & Search</h2>
|
||||
<ul>
|
||||
<li><b>Library</b> (<code>/library</code>) — tabs for Recents, Favorites, Shared, Published, with filtering.</li>
|
||||
<li><b>Trash</b> (<code>/trash</code>) — soft-deleted pages, 30-day retention, restore or purge.</li>
|
||||
<li><b>Ctrl+K</b> — command palette: fuzzy search across pages, jump anywhere, start an AI answer.</li>
|
||||
<li>Full-text search API: <code>/api/v2/search</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='agent'">
|
||||
<h2>AI Agent & Automations</h2>
|
||||
<ul>
|
||||
<li><b>Chat sidebar</b> (Chat tab): conversations with your configured LLM; pages can be attached as context.</li>
|
||||
<li><b>Agent panel</b> on pages: propose edits, generate content, answer questions about the workspace.</li>
|
||||
<li><b>Skill marketplace</b>: export/import portable skills, install presets from the <code>/</code> gallery.</li>
|
||||
<li><b>Automations</b> (Settings → Automations): visual pipeline of trigger → condition → delay → action (webhook, notification, page update…), with run history and legacy JSON conversion.</li>
|
||||
<li>Configure providers in <b>Settings → Agent & IA</b>: per-provider API keys, model lists, offline mode without any provider.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='integrations'">
|
||||
<h2>Integrations & API</h2>
|
||||
<ul>
|
||||
<li><b>Public API v2</b> — <code>/api/v2</code>: CRUD on collections, pages, properties, views, comments, notifications, favorites, tags, sharing, sprints, templates. Bearer tokens (Settings → API tokens) with scopes <code>read</code>/<code>write</code>/<code>admin</code>, pagination, filters, sorting, idempotency keys, RFC 7807 errors. Interactive docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>.</li>
|
||||
<li><b>Agent API</b> — <code>/api/v2/agents/*</code>: agents, conversations, synchronous JSON run, action journal with rollback, external trigger.</li>
|
||||
<li><b>Webhooks</b>: outgoing events (create/update/delete) managed in Settings → Integrations; incoming Gitea webhooks keep boards in sync.</li>
|
||||
<li><b>Web Clipper</b> — browser extension (Manifest V3): clip article / selection / bookmark / screenshot straight into FlowDeck.</li>
|
||||
<li><b>Import / Export</b>: CSV import, JSON/CSV/Markdown export.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='pwa'">
|
||||
<h2>Offline mode (PWA)</h2>
|
||||
<p>FlowDeck is a Progressive Web App: pages you visited stay available offline and your edits are saved locally, then synchronised when the connection returns.</p>
|
||||
<ul>
|
||||
<li><b>Install:</b> browser menu → <i>Install app</i> (Chrome/Edge) or <i>Add to Home Screen</i> (Safari/iOS).</li>
|
||||
<li><b>Offline editing:</b> while offline, the editor stores changes in the browser (IndexedDB) and shows an offline banner with the number of pending changes. A ⟳ icon marks pages with unsynced edits.</li>
|
||||
<li><b>Reconnection:</b> the queue is replayed automatically (and via Background Sync); a spinner badge appears while syncing, then a confirmation toast.</li>
|
||||
<li><b>Conflicts:</b> the latest edit wins with a notice; server-side-deleted pages are recreated as orphan pages; title collisions get an <i>“…(copie offline)”</i> suffix.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='auth'">
|
||||
<h2>Accounts & SSO</h2>
|
||||
<p>
|
||||
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br><br>
|
||||
<span class="help-badge gitea">Gitea OAuth</span> / <span class="help-badge github">GitHub OAuth</span> — sign in with your forge; your repos become workspaces. You can link a forge to an existing local account — your identity stays local.<br><br>
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> — in <b>Settings → Admin → SSO / Enterprise</b>:
|
||||
</p>
|
||||
<ul>
|
||||
<li><b>SAML 2.0</b> — paste IdP Entity ID, SSO URL and signing certificate; give the IdP your <code>/auth/saml/metadata</code> link.</li>
|
||||
<li><b>OpenID Connect</b> — Issuer URL, Client ID and Client Secret (PKCE, scopes <code>openid profile email</code>).</li>
|
||||
<li>Just-in-time provisioning, IdP groups mapped to workspace roles, <i>SSO only</i> mode disables local login (admins keep their door). Every attempt is audited in the login history.</li>
|
||||
</ul>
|
||||
<p>Sessions and API tokens are managed in Settings → Sessions / API tokens.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='shortcuts'">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Command palette / quick find</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (footer)</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">/</span></div><div class="desc">Slash command menu (in editor)</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Tab</span> / <span class="help-kbd">Shift</span>+<span class="help-kbd">Tab</span></div><div class="desc">Indent / outdent block</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='tips'">
|
||||
<h2>Tips</h2>
|
||||
<ul>
|
||||
<li>Toggle the desktop sidebar with the « button; collapsed, hover the left edge to peek it.</li>
|
||||
<li><b>Ctrl+Click</b> / <b>Shift+Click</b> multi-select in the sidebar tree.</li>
|
||||
<li>Hover any sidebar item for quick actions (favorite, share, delete).</li>
|
||||
<li>Drag pages into the tree to reorganize; drop onto a page to nest it.</li>
|
||||
<li>The section <b>⋮</b> menus reorder or hide sidebar sections to taste.</li>
|
||||
<li>Dark/light theme toggle is in the topbar and persists across reloads.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" src="/static/js/help.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
+15
-21
@@ -11,16 +11,6 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
|
||||
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
|
||||
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
|
||||
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
|
||||
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
|
||||
.settings-nav-item:hover{background:var(--bg-hover);}
|
||||
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
|
||||
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
|
||||
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
|
||||
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
|
||||
.setting-group{margin-bottom:28px;}
|
||||
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
||||
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
|
||||
@@ -69,9 +59,7 @@
|
||||
.status-dot.active{background:var(--toast-success-bg);}
|
||||
.status-dot.inactive{background:var(--danger);}
|
||||
|
||||
/* Close button */
|
||||
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
|
||||
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
|
||||
/* Close button — shared styles in /static/css/settings.css */
|
||||
|
||||
/* Agent & IA — status summary + provider cards */
|
||||
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
|
||||
@@ -125,26 +113,32 @@
|
||||
.llm-detail .prov-field-row{margin-top:12px;}
|
||||
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
|
||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||
|
||||
/* ── Mobile side-nav drawer: shared settings.css handles overlay/nav/panel ── */
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<div class="settings-overlay" x-data="settingsInit()">
|
||||
<div class="settings-panel" style="position:relative;" @keydown.escape="historyBack()">
|
||||
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||
|
||||
<!-- Left navigation -->
|
||||
<div class="settings-nav">
|
||||
<!-- Left navigation (mobile: side-navigation drawer via hamburger + backdrop) -->
|
||||
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
|
||||
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
|
||||
<div class="settings-nav-header">Account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</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==='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-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-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==='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>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
@@ -423,7 +417,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Extensions (Web Clipper) -->
|
||||
<div x-show="activeSection==='extensions'">
|
||||
<div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} && activeSection==='extensions'">
|
||||
<h2>Extensions</h2>
|
||||
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
||||
<div class="setting-group">
|
||||
@@ -584,7 +578,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Automations -->
|
||||
<div x-show="activeSection==='automations'">
|
||||
<div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} && activeSection==='automations'">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# V74 — Menu + de l'assistant : hub de contexte (design)
|
||||
|
||||
> **Statut** : design — **phases 1 et 2 livrées (v7.51.0, v7.52.0, 2026-10-06)** ;
|
||||
> phases 3-8 restent à livrer. Adaptations faites en cours de route : le parcours
|
||||
> **Statut** : design — **phases 1 à 6 livrées (v7.51.0 → v7.56.0, 2026-10-06)** ;
|
||||
> phases 7-8 restent à livrer. La **mémoire** a été simplifiée par rapport au
|
||||
> modèle ici : une seule ligne résumé **par conversation** (pas de lignes
|
||||
> `workspace_id NULL` — rien ne les écrivait), extraction déterministe sans LLM. Adaptations faites en cours de route : le parcours
|
||||
> utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat
|
||||
> dossier = `icon === 'folder'`) et la gestion des skills est une **section du menu
|
||||
> avec formulaire intégré** (pas une modale) — `PATCH /api/agent/skills/{id}` ajouté.
|
||||
> dossier = `icon === 'folder'`), la gestion des skills est une **section du menu
|
||||
> avec formulaire intégré** (pas une modale — `PATCH /api/agent/skills/{id}`
|
||||
> ajouté), et les canevas vivent sous **`/board/api/page-templates`** (prefix
|
||||
> `/board`) avec la nouvelle route `GET …/{id}/blocks` pour l'insertion.
|
||||
> **Plan phasé** : `ROADMAP.md` → section « v7.51.0 → v7.58.0 — Menu + de l'assistant ».
|
||||
> **Date** : 2026-10-06 · Version cible de départ : **v7.51.0** (phase 1).
|
||||
|
||||
@@ -198,6 +202,49 @@ transaction par migration — A31).
|
||||
Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, ROADMAP
|
||||
à jour, tag.
|
||||
|
||||
## 9bis. Avenant phase 7 (v7.57.0) — décisions livrées
|
||||
|
||||
- **D5 (`probe/list_sources/fetch` par adapter) écartée** : 1 switch sur
|
||||
`agent_connectors.kind` dans `probe()` / `connector_fetch()` — une interface
|
||||
à 3 méthodes pour 4 kinds tordrait plus qu'elle ne simplifie ; ajouter un
|
||||
kind = 1 branche de plus dans le même switch.
|
||||
- **Discord / Telegram = presets du socle**, pas 3 adapters dédiés : le champ
|
||||
**Type** du formulaire pré-remplit l'URL + le schéma d'auth
|
||||
(`bearer`/`bot`/`none`). La Bot API de Telegram impose le jeton **dans l'URL**
|
||||
→ substitut `{secret}` remplacé à l'appel (`connectors._with_secret`),
|
||||
stockage = motif seul, test « le jeton n'est jamais en base ».
|
||||
- **MCP : outils dynamiques sans état** — `ToolRegistry._all()` relit le cache
|
||||
`tools_json` à chaque run (rien à invalider, « Tester » suffit) ; nom LLM
|
||||
`mcp_<serveur>_<outil>` (slug sans double soulignement) ; dispatch
|
||||
`tools/call` dans `McpTool.execute()`. Serveur désactivé → outil **absent du
|
||||
schéma**, échec réseau → `ToolResult(error)` : le run continue toujours.
|
||||
- **Pas de client SSE à l'état** : `parse_body()` lit le JSON direct ou le
|
||||
premier `data:` d'un `text/event-stream`, ce qui couvre
|
||||
`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.
|
||||
|
||||
+507
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.52.0"
|
||||
"version": "7.61.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -1098,7 +1098,7 @@
|
||||
"dashboard"
|
||||
],
|
||||
"summary": "Help Page",
|
||||
"description": "Comprehensive help & documentation page.",
|
||||
"description": "Help & documentation page — settings-style panel with side navigation.",
|
||||
"operationId": "help_page_help_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -3706,6 +3706,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/board/api/page-templates/{template_id}/blocks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"board"
|
||||
],
|
||||
"summary": "Page Template Blocks",
|
||||
"description": "v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.\n\nBuiltin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).\nCanevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le\nfront appelle ``ensureBlockIds()`` (déjà global côté éditeur).",
|
||||
"operationId": "page_template_blocks_board_api_page_templates__template_id__blocks_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "template_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Template Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "Key"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/board/library": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -16189,6 +16240,460 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/connectors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "List Connectors Route",
|
||||
"description": "Catalogue : natifs (statut dérivé de la config) + personnels.",
|
||||
"operationId": "list_connectors_route_api_agent_connectors_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Create Connectors Route",
|
||||
"description": "Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF).",
|
||||
"operationId": "create_connectors_route_api_agent_connectors_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"title": "Body",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Update Connectors Route",
|
||||
"operationId": "update_connectors_route_api_agent_connectors__connector_id__patch",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "connector_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Connector Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Delete Connectors Route",
|
||||
"operationId": "delete_connectors_route_api_agent_connectors__connector_id__delete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "connector_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Connector Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/connectors/probe": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Probe Connectors Route",
|
||||
"description": "Teste un connecteur (id, nom ou kind natif) et persiste le résultat.",
|
||||
"operationId": "probe_connectors_route_api_agent_connectors_probe_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"title": "Body",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/connectors/oauth/{kind}/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Connector Oauth Status",
|
||||
"description": "État du connecteur OAuth pour l'utilisateur courant.",
|
||||
"operationId": "connector_oauth_status_api_agent_connectors_oauth__kind__status_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Kind"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/connectors/oauth/{kind}/authorize": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Connector Oauth Authorize",
|
||||
"description": "URL d'autorisation (PKCE) + cookies d'état éphémères (10 min).",
|
||||
"operationId": "connector_oauth_authorize_api_agent_connectors_oauth__kind__authorize_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Kind"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/connectors/oauth/{kind}/callback": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Connector Oauth Callback",
|
||||
"description": "Reçoit le code du fournisseur, échange, stocke, renvoie sur la page.",
|
||||
"operationId": "connector_oauth_callback_api_agent_connectors_oauth__kind__callback_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Kind"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "Code"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "State"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "Error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/connectors/oauth/{kind}/disconnect": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Connector Oauth Disconnect",
|
||||
"operationId": "connector_oauth_disconnect_api_agent_connectors_oauth__kind__disconnect_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Kind"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/mentions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Mobile regression: hamburger drawer, wheel scroll, settings side-nav, help page.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-pass-123';
|
||||
|
||||
// One login for the whole file (app rate limiter: 60 req/min per IP) — reused
|
||||
// as storageState by every test.
|
||||
let STATE = null;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 10000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E Mobile' } });
|
||||
if (!resp.ok() && resp.status() !== 409) throw new Error(`register ${resp.status()}`);
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
await page.waitForURL('**/workspaces', { timeout: 10000 });
|
||||
}
|
||||
STATE = await ctx.storageState();
|
||||
await ctx.close();
|
||||
});
|
||||
async function login(page) {
|
||||
// Session reused from the single beforeAll login (rate limiter: 60 req/min/IP)
|
||||
await page.context().addCookies(STATE.cookies);
|
||||
}
|
||||
|
||||
test.describe('mobile', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test('hamburger opens mobile sidebar drawer', async ({ page }) => {
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message));
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const burger = page.locator('.hamburger-btn');
|
||||
await expect(burger).toBeVisible();
|
||||
|
||||
// Sidebar starts off-canvas
|
||||
const sidebar = page.locator('#sidebar');
|
||||
const boxBefore = await sidebar.boundingBox();
|
||||
expect(boxBefore.x).toBeLessThan(0);
|
||||
|
||||
await burger.click();
|
||||
await expect(sidebar).toHaveClass(/mobile-open/, { timeout: 5000 });
|
||||
const boxAfter = await sidebar.boundingBox();
|
||||
expect(boxAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(errs).toEqual([]);
|
||||
|
||||
// Overlay tap closes it
|
||||
await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 400 } });
|
||||
await expect(sidebar).not.toHaveClass(/mobile-open/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('mobile sidebar drawer scrolls with mouse wheel', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.hamburger-btn').click();
|
||||
await page.waitForSelector('#sidebar.mobile-open');
|
||||
const sc = page.locator('.sidebar-scroll');
|
||||
await expect.poll(async () => sc.evaluate((el) => el.scrollHeight > el.clientHeight), { timeout: 3000 }).toBe(true);
|
||||
const box = await sc.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect.poll(async () => sc.evaluate((el) => el.scrollTop), { timeout: 3000 }).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('settings page fits mobile viewport', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
const panel = page.locator('.settings-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const box = await panel.boundingBox();
|
||||
expect(box.width).toBeLessThanOrEqual(390);
|
||||
expect(box.height).toBeLessThanOrEqual(844);
|
||||
|
||||
// No horizontal page overflow
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('settings mobile nav is a hamburger side drawer', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
const burger = page.locator('.settings-menu-btn');
|
||||
await expect(burger).toBeVisible();
|
||||
const nav = page.locator('.settings-nav');
|
||||
const before = await nav.boundingBox();
|
||||
expect(before.x + before.width).toBeLessThanOrEqual(1);
|
||||
|
||||
await burger.click();
|
||||
await expect(nav).toHaveClass(/nav-open/);
|
||||
|
||||
// Nav item switches section AND closes the drawer
|
||||
await page.locator('.settings-nav-item', { hasText: 'Tags' }).click();
|
||||
await expect(nav).not.toHaveClass(/nav-open/);
|
||||
await expect(page.locator('h2', { hasText: 'Tags' }).first()).toBeVisible();
|
||||
|
||||
// Re-open and dismiss via backdrop
|
||||
await burger.click();
|
||||
await expect(nav).toHaveClass(/nav-open/);
|
||||
await page.locator('.settings-nav-backdrop').click({ position: { x: 375, y: 500 } });
|
||||
await expect(nav).not.toHaveClass(/nav-open/);
|
||||
});
|
||||
|
||||
test('help page uses settings-style side navigation', async ({ page }) => {
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message));
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Same panel skeleton as settings
|
||||
await expect(page.locator('.settings-panel')).toBeVisible();
|
||||
await expect(page.locator('.settings-menu-btn')).toBeVisible();
|
||||
|
||||
// Mobile: nav drawer behind hamburger
|
||||
const nav = page.locator('.settings-nav');
|
||||
await expect(nav).not.toHaveClass(/nav-open/);
|
||||
await page.locator('.settings-menu-btn').click();
|
||||
await expect(nav).toHaveClass(/nav-open/);
|
||||
await page.locator('.settings-nav-item', { hasText: 'Databases' }).click();
|
||||
await expect(nav).not.toHaveClass(/nav-open/);
|
||||
await expect(page.locator('h2', { hasText: 'Databases & Views' })).toBeVisible();
|
||||
|
||||
// App-wide coverage: all sections exist in the nav
|
||||
const sections = ['Getting Started', 'Pages & Editor', 'Databases & Views', 'Tasks & Dependencies',
|
||||
'Workspaces & Forges', 'Collaboration', 'Library, Trash & Search', 'AI Agent & Automations',
|
||||
'Integrations & API', 'Offline & PWA', 'Accounts & SSO', 'Keyboard Shortcuts', 'Tips'];
|
||||
const labels = (await nav.locator('.settings-nav-item').allInnerTexts()).join(' ');
|
||||
for (const s of sections) expect(labels, s).toContain(s);
|
||||
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('desktop', () => {
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test('settings and help keep the 2-column layout on desktop', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.settings-menu-btn')).toBeHidden();
|
||||
const nav = page.locator('.settings-nav');
|
||||
await expect(nav).toBeVisible();
|
||||
const navBox = await nav.boundingBox();
|
||||
expect(navBox.height).toBeGreaterThan(navBox.width);
|
||||
|
||||
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.settings-menu-btn')).toBeHidden();
|
||||
await page.locator('.settings-nav-item', { hasText: 'SSO' }).click();
|
||||
await expect(page.locator('h2', { hasText: 'Accounts & SSO' })).toBeVisible();
|
||||
await expect(page.locator('.settings-content')).toContainText('IndexedDB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test('diagnose mobile sidebar wheel scroll', async ({ page }) => {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', process.env.FD_USER || '[email protected]');
|
||||
await page.fill('#password', process.env.FD_PASS || 'e2e-pass-123');
|
||||
await page.click('.btn-primary');
|
||||
await page.waitForURL('**/workspaces', { timeout: 10000 }).catch(() => {});
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.querySelector('.hamburger-btn'));
|
||||
await page.locator('.hamburger-btn').click();
|
||||
await page.waitForSelector('#sidebar.mobile-open');
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const sc = document.querySelector('.sidebar-scroll');
|
||||
const cs = getComputedStyle(sb);
|
||||
const ccs = sc ? getComputedStyle(sc) : null;
|
||||
return {
|
||||
sidebar: { display: cs.display, height: cs.height, overflow: cs.overflow, sh: sb.scrollHeight, ch: sb.clientHeight },
|
||||
scroll: sc ? { top: sc.scrollTop, sh: sc.scrollHeight, ch: sc.clientHeight, oy: ccs.overflowY, flex: ccs.flex, rect: JSON.stringify(sc.getBoundingClientRect()) } : null,
|
||||
wheelTarget: (() => { const el = document.elementFromPoint(150, 400); return el ? el.className : 'none'; })(),
|
||||
};
|
||||
});
|
||||
console.log('DIAG:', JSON.stringify(info, null, 1));
|
||||
|
||||
await page.mouse.move(150, 400);
|
||||
await page.mouse.wheel(0, 300);
|
||||
await page.waitForTimeout(300);
|
||||
const after = await page.evaluate(() => {
|
||||
const sc = document.querySelector('.sidebar-scroll');
|
||||
return { scTop: sc.scrollTop, scSH: sc.scrollHeight, scCH: sc.clientHeight, bodyTop: document.scrollingElement.scrollTop };
|
||||
});
|
||||
console.log('AFTER WHEEL:', JSON.stringify(after));
|
||||
});
|
||||
+70
-4
@@ -2341,6 +2341,36 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* + « ajouter un bloc dessous » — juste à gauche du handle (gouttière 64px) */
|
||||
.block-insert-btn {
|
||||
position: absolute;
|
||||
left: -64px;
|
||||
top: 5px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: var(--text-dim);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.block-wrapper:hover .block-insert-btn,
|
||||
.block-wrapper:focus-within .block-insert-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.block-insert-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
|
||||
.block-wrapper.selected {
|
||||
background: rgba(35,131,226,0.06);
|
||||
@@ -2376,6 +2406,30 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; }
|
||||
.block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); }
|
||||
.block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; }
|
||||
/* ── v7.61 : menu contextuel type Notion — recherche, sections, pied, sous-menus ── */
|
||||
.block-menu .bm-group {
|
||||
padding: 9px 9px 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.block-menu .bm-foot {
|
||||
border-top: 1px solid var(--border, #2a2a2a);
|
||||
margin-top: 6px;
|
||||
padding: 8px 9px 6px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.block-menu .bm-check { margin-left: auto; color: var(--accent, #2383e2); font-size: 12px; }
|
||||
.block-menu .bm-item.bm-current { background: var(--bg-secondary); }
|
||||
.block-menu .bm-dot {
|
||||
display: inline-block; width: 12px; height: 12px; border-radius: 3px;
|
||||
border: 1px solid var(--border, #333); vertical-align: -2px;
|
||||
}
|
||||
.block-menu .bm-search:focus { border-color: var(--accent, #2383e2); }
|
||||
.block-menu .bm-colors { padding: 6px 9px; }
|
||||
.block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
|
||||
.block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
@@ -2406,7 +2460,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px;
|
||||
}
|
||||
.block-menu .bm-cancel:hover { background: var(--bg-hover); }
|
||||
.block-menu-sub { min-width: 180px; max-height: 320px; overflow-y: auto; }
|
||||
.block-menu-sub { min-width: 230px; max-height: 60vh; overflow-y: auto; }
|
||||
|
||||
.ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; }
|
||||
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
|
||||
@@ -2476,11 +2530,20 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Numbered list */
|
||||
/* Numbered list — data-num est calculé au rendu (rangée contiguë) */
|
||||
.block-numbered {
|
||||
padding-left: 22px;
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
}
|
||||
.block-numbered[data-num]::before {
|
||||
content: attr(data-num) ". ";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
min-width: 24px;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* To-do */
|
||||
.block-todo {
|
||||
@@ -2925,6 +2988,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-title-input { font-size: 28px; }
|
||||
.blocks-container { padding: 8px 16px 96px; }
|
||||
.block-handle { display: none; }
|
||||
.block-insert-btn { display: none; }
|
||||
.block-actions { display: none; }
|
||||
.editor-statusbar { left: 0; }
|
||||
}
|
||||
@@ -3400,6 +3464,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-cover-area { padding: 32px 32px 8px; }
|
||||
.blocks-container { padding: 8px 32px 80px; }
|
||||
.block-handle { left: -32px; }
|
||||
.block-insert-btn { left: -56px; }
|
||||
.page-title-block::before { left: -32px; }
|
||||
.page-title-input { font-size: 32px; }
|
||||
|
||||
@@ -3424,7 +3489,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
|
||||
/* ── Sidebar mobile ── */
|
||||
.sidebar {
|
||||
display: block;
|
||||
display: flex; flex-direction: column; /* block cassait flex:1 de .sidebar-scroll → drawer impossible à scroller */
|
||||
position: fixed !important;
|
||||
top: 0; left: 0;
|
||||
bottom: 0; width: 85vw; max-width: 320px;
|
||||
@@ -3490,6 +3555,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-title-input { font-size: 24px; }
|
||||
.blocks-container { padding: 8px 16px 96px; }
|
||||
.block-handle { display: none; }
|
||||
.block-insert-btn { display: none; }
|
||||
.block-actions { display: none; }
|
||||
.page-title-block::before { display: none; }
|
||||
.page-title-block .page-icon-emoji { margin-left: 0; }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/* ═══ Panel skeleton shared by /settings and /help (Notion-style modal) ═══ */
|
||||
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
|
||||
.settings-panel{position:relative;display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
|
||||
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
|
||||
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
|
||||
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
|
||||
.settings-nav-item:hover{background:var(--bg-hover);}
|
||||
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
|
||||
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
|
||||
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
|
||||
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
|
||||
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;text-decoration:none;}
|
||||
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
|
||||
|
||||
/* Mobile hamburger for the panel nav — hidden on desktop */
|
||||
.settings-menu-btn{display:none;position:absolute;top:8px;left:8px;z-index:20;width:40px;height:40px;border:none;border-radius:8px;background:var(--bg-tertiary);color:var(--text);align-items:center;justify-content:center;cursor:pointer;}
|
||||
.settings-nav-backdrop{display:none;}
|
||||
|
||||
/* ── Mobile: nav becomes a left side-navigation drawer (hamburger opens it) ── */
|
||||
@media (max-width:768px){
|
||||
.settings-overlay{background:var(--bg-primary);backdrop-filter:none;}
|
||||
.settings-panel{flex-direction:column;width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;border:none;border-radius:0;box-shadow:none;}
|
||||
.settings-menu-btn{display:flex;}
|
||||
.settings-nav{position:fixed;top:0;left:0;bottom:0;width:260px;max-width:80vw;min-width:0;z-index:600;transform:translateX(-100%);transition:transform .2s cubic-bezier(0.4,0,0.2,1);border-right:1px solid var(--border);box-shadow:4px 0 24px rgba(0,0,0,.4);padding-top:8px;}
|
||||
.settings-nav.nav-open{transform:translateX(0);}
|
||||
.settings-nav-item{padding:10px 16px;}
|
||||
.settings-nav-backdrop{display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:599;}
|
||||
.settings-content{padding:56px 16px 16px;}
|
||||
.settings-close{top:8px;right:8px;width:44px;height:44px;background:var(--bg-tertiary);}
|
||||
.setting-row{flex-wrap:wrap;}
|
||||
.setting-control{margin-left:0;width:100%;}
|
||||
.prov-grid{grid-template-columns:1fr;}
|
||||
.avatar-section{gap:10px;}
|
||||
}
|
||||
+401
-6
@@ -40,13 +40,13 @@
|
||||
{key:'skills', icon:'✨', label:'Compétences-skills',
|
||||
sub:'Deep research, Skill-creator… · Gérer · Parcourir', action:'skills', chev:true},
|
||||
{key:'connectors', icon:'🔌', label:'Connecteurs',
|
||||
sub:'Parcourir / connecteur personnalisé — bientôt (phase 5)', disabled:true},
|
||||
sub:'Parcourir les connecteurs · ajouter un personnalisé', action:'connectors', chev:true},
|
||||
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
|
||||
sub:'Galerie de canevas — bientôt (phase 3)', disabled:true},
|
||||
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:'Mémoire persistante — bientôt (phase 4)', disabled:true}
|
||||
sub:'Résumés des échanges injectés à chaque run', action:'memory'}
|
||||
];
|
||||
var FD_PLUS_FILES = [
|
||||
{key:'f-search', icon:'🔍', label:'Rechercher…',
|
||||
@@ -73,6 +73,10 @@
|
||||
plusBrowse: [], plusCrumb: [],
|
||||
skillForm: {id: null, name: '', description: '', prompt_template: ''},
|
||||
galleryFilter: '',
|
||||
canvases: [], canvas: null,
|
||||
connectors: [], connector: null, plugins: [],
|
||||
connectorForm: {name: '', url: '', secret: '', kind: 'custom',
|
||||
auth: 'bearer', hint: ''},
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
toastMsg: '', toastErr: false, _toastTimer: null,
|
||||
_livePh: null,
|
||||
@@ -83,6 +87,18 @@
|
||||
init(){
|
||||
var self = this;
|
||||
var el = document.getElementById('fd-agent-panel');
|
||||
// Retour du flux OAuth (Google / M365) : signaler le résultat puis nettoyer l'URL.
|
||||
try{
|
||||
var qs = new URLSearchParams(window.location.search);
|
||||
if(qs.get('oauth') || qs.get('oauth_error')){
|
||||
this.toast(qs.get('oauth_error')
|
||||
? 'Connexion du connecteur refusée : ' + qs.get('oauth_error')
|
||||
: 'Connecteur connecté.', !!qs.get('oauth_error'));
|
||||
qs.delete('oauth'); qs.delete('oauth_error');
|
||||
window.history.replaceState(null, '', window.location.pathname
|
||||
+ (qs.toString() ? '?' + qs.toString() : '') + window.location.hash);
|
||||
}
|
||||
}catch{ /* volontaire */ }
|
||||
|
||||
function applyState(){
|
||||
if(el){ if(self.open){ el.classList.add('open'); el.setAttribute('aria-hidden','false'); } else { el.classList.remove('open'); el.setAttribute('aria-hidden','true'); } }
|
||||
@@ -645,7 +661,8 @@
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
var conv = {id:d.id, title:d.title, memory_enabled:d.memory_enabled,
|
||||
updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat';
|
||||
self.contextChips = []; self.mentionOpen = false;
|
||||
@@ -662,7 +679,8 @@
|
||||
self._ensuring = fetch('/api/agent/conversations', {
|
||||
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
var conv = {id:d.id, title:d.title, memory_enabled:d.memory_enabled,
|
||||
updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat';
|
||||
self.contextChips = []; self.mentionOpen = false;
|
||||
@@ -1125,6 +1143,12 @@
|
||||
if(s === 'skills-manage') return 'Gérer les compétences';
|
||||
if(s === 'skills-gallery') return 'Parcourir les compétences';
|
||||
if(s === 'skills-edit') return this.skillForm.id ? 'Modifier le skill' : 'Nouveau skill';
|
||||
if(s === 'connectors') return 'Connecteurs';
|
||||
if(s === 'connector') return this.connector ? this.connector.name : 'Connecteur';
|
||||
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(){
|
||||
@@ -1135,8 +1159,50 @@
|
||||
if(s === 'skills-manage') return this._manageItems();
|
||||
if(s === 'skills-gallery') return this._galleryItems();
|
||||
if(s === 'skills-edit') return []; // formulaire, pas de liste
|
||||
if(s === 'connectors') return this._connectorItems();
|
||||
if(s === 'connector') return this._connectorActionItems();
|
||||
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;
|
||||
return FD_PLUS_MENU.map(function(it){
|
||||
if(it.key !== 'memory') return it;
|
||||
return {key:'memory', icon: on ? '🧠' : '💭', label:'Mémoire (on/off)',
|
||||
sub: on ? 'Activée — résumés des échanges injectés à chaque run'
|
||||
: 'Désactivée — contexte strict du run courant',
|
||||
action:'memory'};
|
||||
});
|
||||
}
|
||||
return FD_PLUS_MENU;
|
||||
},
|
||||
// État du toggle mémoire de la conversation courante (défaut : activée).
|
||||
get memoryOn(){
|
||||
if(!this.currentConv) return true;
|
||||
var v = this.currentConv.memory_enabled;
|
||||
return v === undefined || v === null ? true : !!Number(v);
|
||||
},
|
||||
// Bascule le toggle mémoire (PATCH /api/agent/conversations/{id}).
|
||||
toggleMemory(){
|
||||
var self = this, conv = this.currentConv;
|
||||
if(!conv || !conv.id){
|
||||
this.toast('Ouvrez une conversation pour activer la mémoire.', true);
|
||||
return;
|
||||
}
|
||||
var next = this.memoryOn ? 0 : 1;
|
||||
fetch('/api/agent/conversations/' + conv.id, {
|
||||
method: 'PATCH', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({memory_enabled: next})
|
||||
}).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; }
|
||||
conv.memory_enabled = next;
|
||||
self.toast(next ? 'Mémoire activée — résumés injectés à chaque run.'
|
||||
: 'Mémoire désactivée — contexte strict du run courant.');
|
||||
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
|
||||
},
|
||||
// Sous-menu Compétences : builtin (alias affiché) + installés, puis les 2 actions.
|
||||
_skillsItems(){
|
||||
var out = [];
|
||||
@@ -1187,6 +1253,299 @@
|
||||
if(!out.length) out.push({key:'g-none', _sep: f ? 'Aucun résultat' : 'Galerie vide'});
|
||||
return out;
|
||||
},
|
||||
// ── Connecteurs (v7.55.0) ──
|
||||
fetchConnectors(){
|
||||
var self = this;
|
||||
fetch('/api/agent/connectors').then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
self.connectors = d.connectors || [];
|
||||
// garde la fiche ouverte alignée sur la liste rafraîchie
|
||||
var sel = self.connector;
|
||||
if(sel){
|
||||
var found = null;
|
||||
self.connectors.forEach(function(c){
|
||||
if((sel.id != null && c.id === sel.id) || (sel.id == null && c.kind === sel.kind)) found = c;
|
||||
});
|
||||
self.connector = found;
|
||||
}
|
||||
}).catch(function(){ self.connectors = []; });
|
||||
},
|
||||
_connectorStatus(c){
|
||||
var mark = c.status === 'ok' ? '✓ ' : (c.status === 'error' ? '✗ '
|
||||
: (c.status === 'missing' ? '⚠ ' : '? '));
|
||||
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){
|
||||
return {key:'cn-' + (c.id != null ? c.id : c.kind), icon: c.builtin ? '🌐' : '🔌',
|
||||
label: c.name, sub: self._connectorStatus(c),
|
||||
action:'connector', connector:c, chev:true};
|
||||
});
|
||||
if(!out.length) out.push({key:'cn-none', _sep:'Aucun connecteur'});
|
||||
out.push({key:'cn-sep', _sep:'Ajout'});
|
||||
out.push({key:'cn-new', icon:'+', label:'Ajouter un connecteur personnalisé',
|
||||
sub:'URL publique + clé API', action:'connector-new', chev:true});
|
||||
return out;
|
||||
},
|
||||
_connectorActionItems(){
|
||||
var c = this.connector;
|
||||
if(!c) return [];
|
||||
var items = [
|
||||
{key:'cn-meta', _sep: (c.builtin ? 'Connecteur natif · ' : 'Connecteur · ') + c.name},
|
||||
{key:'cn-probe', icon:'↻', label:'Tester le connecteur',
|
||||
sub:'Appel de contrôle — status + détail', action:'connector-probe'}
|
||||
];
|
||||
if(c.oauth){
|
||||
var connected = c.status === 'ok';
|
||||
items.push({key:'cn-oauth', icon: connected ? '🔓' : '🔑',
|
||||
label: connected ? 'Déconnecter' : 'Se connecter',
|
||||
sub: connected ? 'Retire les autorisations de ce compte'
|
||||
: 'Ouvre la page de connexion du fournisseur',
|
||||
action: connected ? 'connector-disconnect' : 'connector-connect'});
|
||||
}
|
||||
if(!c.builtin){
|
||||
items.push({key:'cn-toggle', icon: c.enabled ? '⏸' : '▶',
|
||||
label: c.enabled ? 'Désactiver' : 'Activer',
|
||||
sub:'Désactivé = lecture refusée par l\u2019outil', action:'connector-toggle'});
|
||||
items.push({key:'cn-del', icon:'🗑️', label:'Supprimer le connecteur',
|
||||
sub:'Supprime aussi sa clé', action:'connector-delete'});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
_connectorRef(c){
|
||||
c = c || this.connector;
|
||||
return c ? (c.id != null ? String(c.id) : c.kind) : '';
|
||||
},
|
||||
connectorProbe(){
|
||||
var self = this;
|
||||
fetch('/api/agent/connectors/probe', {
|
||||
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({connector: this._connectorRef()})
|
||||
}).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) || 'Test impossible.', true); return; }
|
||||
self.toast('Connecteur : ' + res.d.status + ' — ' + (res.d.detail || ''));
|
||||
self.fetchConnectors();
|
||||
}).catch(function(){ self.toast('Test impossible (réseau).', true); });
|
||||
},
|
||||
connectorToggle(){
|
||||
var self = this, c = this.connector;
|
||||
if(!c || c.builtin || c.id == null) return;
|
||||
fetch('/api/agent/connectors/' + c.id, {
|
||||
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({enabled: !c.enabled})
|
||||
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
|
||||
.then(function(res){
|
||||
if(!res.ok){ self.toast('Bascule impossible.', true); return; }
|
||||
self.toast(res.d.enabled ? 'Connecteur activé.' : 'Connecteur désactivé.');
|
||||
self.fetchConnectors();
|
||||
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
|
||||
},
|
||||
// ── Connexion OAuth Google / M365 (v7.56.0) ──
|
||||
connectorConnect(){
|
||||
var self = this, c = this.connector;
|
||||
if(!c || !c.oauth) return;
|
||||
fetch('/api/agent/connectors/oauth/' + c.kind + '/authorize', {
|
||||
method:'POST', headers:{'X-CSRF-Token': getCsrf()}
|
||||
}).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) || 'Connexion impossible.', true); return; }
|
||||
window.location.href = res.d.url; // → fournisseur → callback → retour ici
|
||||
}).catch(function(){ self.toast('Connexion impossible (réseau).', true); });
|
||||
},
|
||||
connectorDisconnect(){
|
||||
var self = this, c = this.connector;
|
||||
if(!c || !c.oauth) return;
|
||||
fetch('/api/agent/connectors/oauth/' + c.kind + '/disconnect', {
|
||||
method:'POST', headers:{'X-CSRF-Token': getCsrf()}
|
||||
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
|
||||
.then(function(res){
|
||||
if(!res.ok){ self.toast('Déconnexion impossible.', true); return; }
|
||||
self.toast('Connecteur déconnecté.');
|
||||
self.fetchConnectors();
|
||||
}).catch(function(){ self.toast('Déconnexion impossible (réseau).', true); });
|
||||
},
|
||||
connectorDelete(){
|
||||
var self = this, c = this.connector;
|
||||
if(!c || c.builtin || c.id == null) return;
|
||||
if(!window.confirm('Supprimer le connecteur « ' + c.name + ' » ?')) return;
|
||||
fetch('/api/agent/connectors/' + c.id, {method:'DELETE', headers:{'X-CSRF-Token': getCsrf()}})
|
||||
.then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
|
||||
.then(function(res){
|
||||
if(!res.ok){ self.toast('Suppression impossible.', true); return; }
|
||||
self.toast('Connecteur supprimé.');
|
||||
self.connector = null;
|
||||
self.plusSection = 'connectors'; self.plusFocus = 0;
|
||||
self.fetchConnectors();
|
||||
}).catch(function(){ self.toast('Suppression impossible (réseau).', true); });
|
||||
},
|
||||
// Type choisi dans le formulaire → URL / schéma d'auth / aide pré-remplis.
|
||||
connectorPreset(){
|
||||
var f = this.connectorForm;
|
||||
var presets = {
|
||||
custom: {url:'', auth:'bearer', hint:'URL publique de votre API (hôtes privés refusés)'},
|
||||
discord: {url:'https://discord.com/api/v10', auth:'bot',
|
||||
hint:'Discord Developers → Bot Token (jeton en Authorization)'},
|
||||
telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none',
|
||||
hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'},
|
||||
mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'}
|
||||
};
|
||||
var p = presets[f.kind] || presets.custom;
|
||||
f.url = p.url; f.auth = p.auth; f.hint = p.hint;
|
||||
},
|
||||
connectorCreate(){
|
||||
var self = this, f = this.connectorForm;
|
||||
if(!(f.name || '').trim() || !(f.url || '').trim()){
|
||||
this.toast('Nom et URL requis.', true); return;
|
||||
}
|
||||
fetch('/api/agent/connectors', {
|
||||
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: f.name, url: f.url, secret: f.secret || '',
|
||||
kind: f.kind || 'custom', auth: f.auth || 'bearer'})
|
||||
}).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) || 'Création impossible.', true); return; }
|
||||
self.toast('Connecteur « ' + res.d.name + ' » ajouté.');
|
||||
self.connectorForm = {name:'', url:'', secret:''};
|
||||
self.plusSection = 'connectors'; self.plusFocus = 0;
|
||||
self.fetchConnectors();
|
||||
}).catch(function(){ self.toast('Création impossible (réseau).', true); });
|
||||
},
|
||||
// ── Design System – Canevas (v7.53.0) ──
|
||||
fetchCanvases(){
|
||||
var self = this;
|
||||
fetch('/board/api/page-templates').then(function(r){ return r.json(); })
|
||||
.then(function(d){ self.canvases = d.templates || []; })
|
||||
.catch(function(){ self.canvases = []; });
|
||||
},
|
||||
_canvasItems(){
|
||||
return (this.canvases || []).map(function(t){
|
||||
return {key: 'cv-' + (t.builtin ? ('b-' + t.key) : ('u-' + t.id)),
|
||||
icon: t.icon || '📄', label: t.name,
|
||||
sub: (t.description || (t.builtin ? 'Canevas intégré' : 'Canevas personnel')),
|
||||
action: 'canvas', canvas: t, chev: true};
|
||||
});
|
||||
},
|
||||
_canvasActionItems(){
|
||||
var c = this.canvas;
|
||||
if(!c) return [];
|
||||
var pageId = this._openPageId();
|
||||
return [
|
||||
{key:'cv-meta', _sep: (c.builtin ? 'Canevas intégré · ' : 'Canevas personnel · ') + c.name},
|
||||
{key:'cv-create', icon:'+', label:'Créer une page à partir du canevas',
|
||||
sub:'Nouvelle page dans le workspace courant', action:'canvas-create'},
|
||||
{key:'cv-insert', icon:'⇩', label:'Insérer dans le document ouvert',
|
||||
sub: pageId ? 'Ajoute les blocs en fin de page' : 'Aucun document éditable ouvert',
|
||||
action:'canvas-insert', disabled: !pageId},
|
||||
{key:'cv-save', icon:'⭐', label:'Enregistrer le document ouvert comme canevas',
|
||||
sub: pageId ? 'Réutilisable depuis ce menu' : 'Aucun document ouvert',
|
||||
action:'canvas-save', disabled: !pageId}
|
||||
];
|
||||
},
|
||||
// Id de la page ouverte dans l'éditeur (refId du contexte, sinon l'URL).
|
||||
_openPageId(){
|
||||
try{
|
||||
var c = window.FlowDeckCtx && window.FlowDeckCtx.current();
|
||||
if(c && c.kind === 'page' && c.refId != null && /^\d+$/.test(String(c.refId))) return String(c.refId);
|
||||
}catch{ /* volontaire */ }
|
||||
var m = /\/pages\/(\d+)/.exec(window.location.pathname);
|
||||
return m ? m[1] : null;
|
||||
},
|
||||
_canvasUrl(suffix, t){
|
||||
t = t || this.canvas;
|
||||
return t.builtin
|
||||
? ('/board/api/page-templates/0/' + suffix + '?key=' + encodeURIComponent(t.key))
|
||||
: ('/board/api/page-templates/' + t.id + '/' + suffix);
|
||||
},
|
||||
canvasCreate(){
|
||||
var self = this, c = this.canvas;
|
||||
if(!c) return;
|
||||
// le builtin se choisit par `key` DANS LE BODY (route /use), pas en query
|
||||
fetch(this._canvasUrl('use'), {
|
||||
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(c.builtin ? {key: c.key} : {})
|
||||
}).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) || 'Création impossible.', true); return; }
|
||||
self.toast('Page créée : « ' + ((res.d && res.d.title) || c.name) + ' » (Workspace).');
|
||||
self.plusSection = 'canvases'; self.plusFocus = 0;
|
||||
}).catch(function(){ self.toast('Création impossible (réseau).', true); });
|
||||
},
|
||||
canvasInsert(){
|
||||
var self = this, c = this.canvas;
|
||||
if(!c) return;
|
||||
fetch(this._canvasUrl('blocks')).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
var E = window.E;
|
||||
if(!E || !E.blocks || !d || !Array.isArray(d.blocks)){
|
||||
self.toast('Aucun document éditable ouvert.', true); return;
|
||||
}
|
||||
if(window.ensureBlockIds) window.ensureBlockIds(d.blocks);
|
||||
try{
|
||||
if(E.sync) E.sync();
|
||||
var empty = E.blocks.length === 1 && E.blocks[0].type === 'paragraph'
|
||||
&& !(E.blocks[0].content || '').trim();
|
||||
E.blocks = empty ? d.blocks : E.blocks.concat(d.blocks);
|
||||
E.dirty = true;
|
||||
if(E.autoSave) E.autoSave();
|
||||
if(E.render) E.render();
|
||||
self.toast('Canevas inséré dans le document.');
|
||||
}catch{ self.toast('Insertion impossible.', true); }
|
||||
}).catch(function(){ self.toast('Lecture du canevas impossible.', true); });
|
||||
},
|
||||
canvasSave(){
|
||||
var self = this, c = this.canvas;
|
||||
var pageId = this._openPageId();
|
||||
if(!c || !pageId) return;
|
||||
var def = (this.activeContext && this.activeContext.label) || c.name || 'Mon canevas';
|
||||
var name = window.prompt('Nom du canevas à enregistrer :', def);
|
||||
if(!name || !name.trim()) return;
|
||||
name = name.trim();
|
||||
fetch('/board/api/page-templates', {
|
||||
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, page_id: Number(pageId)})
|
||||
}).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) || 'Enregistrement impossible.', true); return; }
|
||||
self.toast('Canevas enregistré : « ' + name + ' »');
|
||||
self.fetchCanvases();
|
||||
}).catch(function(){ self.toast('Enregistrement impossible (réseau).', true); });
|
||||
},
|
||||
_plusSelectable(){
|
||||
var out = [];
|
||||
this.plusItems.forEach(function(it, i){ if(!it.disabled && !it._sep) out.push(i); });
|
||||
@@ -1214,6 +1573,7 @@
|
||||
this.plusSection = 'browse'; this.plusCrumb = []; this.plusLoadBrowse(null); return;
|
||||
}
|
||||
// ── Compétences (v7.52.0) ──
|
||||
if(it.action === 'memory'){ this.toggleMemory(); return; }
|
||||
if(it.action === 'skills'){ this.plusSection = 'skills'; this.plusFocus = 0; return; }
|
||||
if(it.action === 'skill'){ this.closePlus(); this.useSuggestion(it.suggestion); return; }
|
||||
if(it.action === 'skills-manage'){
|
||||
@@ -1242,6 +1602,37 @@
|
||||
if(this.$refs.skillImport) this.$refs.skillImport.click();
|
||||
return;
|
||||
}
|
||||
// ── Connecteurs (v7.55.0) ──
|
||||
if(it.action === 'connectors'){
|
||||
this.fetchConnectors(); this.plusSection = 'connectors'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'connector'){
|
||||
this.connector = it.connector; this.plusSection = 'connector'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'connector-new'){
|
||||
this.connectorForm = {name:'', url:'', secret:'', kind:'custom',
|
||||
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; }
|
||||
if(it.action === 'connector-connect'){ this.connectorConnect(); return; }
|
||||
if(it.action === 'connector-disconnect'){ this.connectorDisconnect(); return; }
|
||||
// ── Design System – Canevas (v7.53.0) ──
|
||||
if(it.action === 'canvases'){
|
||||
this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'canvas'){
|
||||
this.canvas = it.canvas; this.plusSection = 'canvas'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'canvas-create'){ this.canvasCreate(); return; }
|
||||
if(it.action === 'canvas-insert'){ this.canvasInsert(); return; }
|
||||
if(it.action === 'canvas-save'){ this.canvasSave(); return; }
|
||||
if(it._folder){ // entrer dans un sous-dossier
|
||||
this.plusCrumb.push({id: it._folder, name: it.label});
|
||||
this.plusLoadBrowse(it._folder);
|
||||
@@ -1261,6 +1652,10 @@
|
||||
if(sec === 'skills-manage' || sec === 'skills-gallery'){
|
||||
this.plusSection = 'skills'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(sec === 'canvas'){ this.plusSection = 'canvases'; this.plusFocus = 0; return; }
|
||||
if(sec === 'connector' || sec === 'connector-new'){
|
||||
this.plusSection = 'connectors'; this.plusFocus = 0; return;
|
||||
}
|
||||
this.plusSection = 'root';
|
||||
this.plusFocus = 0;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
if (!window.__fdHelpScriptLoaded) {
|
||||
window.__fdHelpScriptLoaded = true;
|
||||
// Alpine.data register (CSP build ne résout pas les globales window)
|
||||
if (window.Alpine) { Alpine.data('helpInit', helpInit); fdRefreshXData('helpInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('helpInit', helpInit); });
|
||||
function helpInit() {
|
||||
return {
|
||||
section: 'start',
|
||||
navOpen: false,
|
||||
closeNavOnMobile() { this.navOpen = false; },
|
||||
// Bouton ✕ (haut droite, visible aussi en mobile) : ferme l'aide.
|
||||
close() {
|
||||
if (document.referrer && document.referrer !== location.href) { history.back(); }
|
||||
else { location.href = '/workspaces'; }
|
||||
},
|
||||
go(sec) {
|
||||
this.section = sec;
|
||||
this.navOpen = false;
|
||||
var c = document.querySelector('.settings-content');
|
||||
if (c) c.scrollTop = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -419,7 +419,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
|
||||
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
|
||||
// → framed result → "Insert below" → applyAIBlocks.
|
||||
const AIC={_open:false,_idx:-1,_stage:'',_html:'',
|
||||
const AIC={_open:false,_idx:-1,_stage:'',_html:'',_md:'',
|
||||
ROOT:null,
|
||||
mk(){ if(this.ROOT) return this.ROOT;
|
||||
const d=document.createElement('div'); d.id='_aiC'; d.style.cssText='position:fixed;z-index:1200;width:420px;max-width:92vw;display:none';
|
||||
@@ -432,7 +432,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
this.paint(); this.ROOT.style.display='block'; this._open=true; this._focusIngest();
|
||||
document.addEventListener('mousedown',function onDoc(ev){ if(!self.ROOT.contains(ev.target)){ self.close(); document.removeEventListener('mousedown',onDoc); } });
|
||||
},
|
||||
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; if(window.E)window.E.dirty=true; this._stage=''; },
|
||||
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; this._md=''; if(window.E)window.E.dirty=true; this._stage=''; },
|
||||
_anchorRect(idx){ const E=window.E; if(!E||!E.blocks)return{left:Math.max(24,window.innerWidth/2-210),top:window.innerHeight/2,bottom:window.innerHeight/2,width:420};
|
||||
const b=E.blocks[idx]; const el=E.getEl(b?b.id:null); if(el){const r=el.getBoundingClientRect();return r;} return {left:24,top:120,bottom:180,width:420}; },
|
||||
_focusIngest(){ const i=this.ROOT.querySelector('.aici-input'); if(i){i.focus();} },
|
||||
@@ -478,12 +478,17 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
const E=window.E; const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):'';
|
||||
if(!(window.fdAgent&&window.fdAgent.generate)){ this._err('Agent IA non configuré'); return; }
|
||||
window.fdAgent.generate(prompt, ctx).then(function(text){
|
||||
self._html=self._mdToHtml(text||''); self._stage='result'; self.paint();
|
||||
// On conserve le MARKDOWN source : c'est lui qui est inséré (md2b),
|
||||
// pas le texte extrait du HTML rendu qui perd titres/listes/gras.
|
||||
self._md=String(text||'');
|
||||
self._html=self._mdToHtml(self._md); self._stage='result'; self.paint();
|
||||
}).catch(function(err){ self._err((err&&err.message)?err.message:'Erreur'); });
|
||||
},
|
||||
_err(msg){ this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
|
||||
_err(msg){ this._md=''; this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
|
||||
insert(){ const E=window.E; if(!E||typeof E.applyAIBlocks!=='function')return; E.sync();
|
||||
const result=this._resultText(); if(!result)return; E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
|
||||
// Markdown source d'abord ; repli sur le texte visible (erreurs).
|
||||
const result=String(this._md||'').trim()||this._resultText(); if(!result)return;
|
||||
E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
|
||||
_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result'); if(!el)return ''; const r=document.createRange();r.selectNodeContents(el);return r.toString(); },
|
||||
toast(m){ if(window.showToast)window.showToast(m,'info'); }
|
||||
};
|
||||
@@ -595,11 +600,22 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
|
||||
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}${(b.style&&b.style.color)?';color:'+b.style.color:''}"`:((b.style&&(b.style.bgColor||b.style.color))?` style="${(b.style.bgColor?'background:'+b.style.bgColor+';':'')+(b.style.color?'color:'+b.style.color+';':'')}"`:'');
|
||||
const bidAttr=b.type.startsWith('heading_')?` id="h-${(b.id||'').replace(/[^a-zA-Z0-9]/g,'')}"`:'';
|
||||
// Numéro d'ordre : rang des blocs `numbered_list` contigus (1,2,3…) ;
|
||||
// passé en attribut data-num → le CSS le rend hors du contenu éditable.
|
||||
let numAttr='';
|
||||
if(b.type==='numbered_list'&&idx>=0){
|
||||
const bs=(window.E&&window.E.blocks)||[];
|
||||
let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
|
||||
numAttr=` data-num="${idx-s+1}"`;
|
||||
}
|
||||
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
||||
// + « ajouter un bloc dessous », juste avant le handle (gouttière 64px)
|
||||
const insertBtn=idx>=0?`<button type="button" class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})">+</button>`:'';
|
||||
return `<div class="block-wrapper" data-id="${b.id}">
|
||||
${insertBtn}
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
${actions}
|
||||
<${tag}${style}${bidAttr} class="${cls}">${inner}</${tag}>
|
||||
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -905,74 +921,214 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
{id:'numbered_list',name:'Numbered list'},{id:'to_do',name:'To-do'},{id:'toggle',name:'Toggle'},
|
||||
{id:'quote',name:'Quote'},{id:'callout',name:'Callout'},{id:'table',name:'Table'},
|
||||
{id:'math',name:'Math equation'},{id:'columns',name:'Column list'},{id:'button',name:'Button / Automation'},
|
||||
{id:'code',name:'Code block'}
|
||||
{id:'code',name:'Code block'},{id:'synced',name:'Synced block'}
|
||||
];
|
||||
const _BLOCK_COLORS_TEXT=['default','#E5484D','#F76B15','#FFC53D','#46A758','#3E63DD','#8E4EC6','#12A594','#94A3B8'];
|
||||
const _BLOCK_COLORS_BG=['default','rgba(229,72,77,.15)','rgba(247,107,21,.15)','rgba(255,197,61,.15)','rgba(70,154,88,.15)','rgba(62,99,221,.15)','rgba(142,78,198,.15)','rgba(18,165,148,.15)','rgba(148,163,184,.2)'];
|
||||
function _blockMenuClose(){var m=document.getElementById('blockMenu');if(m)m.remove();m=document.getElementById('blockMenuSub');if(m)m.remove();}
|
||||
// Sous-menu « Turn into » — ordre Notion ; id ∈ _BLOCK_TURN_TYPES, cols = nb de colonnes
|
||||
const _BLOCK_TURN_MENU=[
|
||||
{id:'paragraph',name:'Text',icon:'T'},{id:'heading_1',name:'Heading 1',icon:'H1'},
|
||||
{id:'heading_2',name:'Heading 2',icon:'H2'},{id:'heading_3',name:'Heading 3',icon:'H3'},
|
||||
{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},
|
||||
{id:'numbered_list',name:'Numbered list',icon:'1.'},{id:'to_do',name:'To-do list',icon:'☑'},
|
||||
{id:'toggle',name:'Toggle list',icon:'▸'},{id:'code',name:'Code',icon:'</>'},
|
||||
{id:'quote',name:'Quote',icon:'”'},{id:'callout',name:'Callout',icon:'ⓘ'},
|
||||
{id:'math',name:'Block equation',icon:'∑'},{id:'synced',name:'Synced block',icon:'⛓'},
|
||||
{id:'columns',name:'2 columns',icon:'▦',cols:2},{id:'columns',name:'3 columns',icon:'▦',cols:3},
|
||||
{id:'columns',name:'4 columns',icon:'▦',cols:4},{id:'columns',name:'5 columns',icon:'▦',cols:5}
|
||||
];
|
||||
// Couleurs texte/fond alignées par index — noms Notion (Dernière utilisée ailleurs)
|
||||
const _BLOCK_COLOR_NAMES=['Default','Gray','Brown','Orange','Yellow','Green','Blue','Purple','Pink','Red'];
|
||||
const _BLOCK_COLORS_TEXT=['default','#94A3B8','#8A6642','#F76B15','#FFC53D','#46A758','#3E63DD','#8E4EC6','#E5539B','#E5484D'];
|
||||
const _BLOCK_COLORS_BG=['default','rgba(148,163,184,.15)','rgba(138,102,66,.15)','rgba(247,107,21,.15)','rgba(255,197,61,.15)','rgba(70,154,88,.15)','rgba(62,99,221,.15)','rgba(142,78,198,.15)','rgba(229,83,155,.15)','rgba(229,72,77,.15)'];
|
||||
// Raccourcis affichés — implémentés par le handler clavier en bas de fichier
|
||||
const _BM_SHORTCUTS={link:'Alt+⇧+L',duplicate:'Ctrl+D',move:'Ctrl+⇧+P',delete:'Del',comment:'Ctrl+⇧+M',suggest:'Ctrl+⇧+Alt+X',ai:'Ctrl+J',color:'Ctrl+⇧+H'};
|
||||
var _bmSubKind='',_bmSubTimer=null;
|
||||
function _blockMenuClose(){var m=document.getElementById('blockMenu');if(m)m.remove();m=document.getElementById('blockMenuSub');if(m)m.remove();clearTimeout(_bmSubTimer);_bmSubKind='';}
|
||||
function _blockAttr(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
||||
function _blockMenuBid(){var m=document.getElementById('blockMenu');return m?(m.getAttribute('data-bid')||''):'';}
|
||||
// Sous-menu ouvert au survol de l'entrée correspondante (panneau de droite)
|
||||
function _blockSubOpen(kind,bid,anchor){
|
||||
if(!kind||!bid)return;
|
||||
var m=document.getElementById('blockMenu');if(!m)return;
|
||||
if(_bmSubKind===kind&&document.getElementById('blockMenuSub'))return;
|
||||
clearTimeout(_bmSubTimer);_bmSubKind=kind;
|
||||
var r=m.getBoundingClientRect();
|
||||
var a=anchor&&anchor.getBoundingClientRect?anchor.getBoundingClientRect():r;
|
||||
var top=Math.max(8,Math.min(a.top,window.innerHeight-60));
|
||||
if(kind==='turn')_blockTurnMenuOpen(r.right+4,top,bid);
|
||||
else if(kind==='color')_blockColorMenuOpen(r.right+4,top,bid);
|
||||
else if(kind==='skills')_blockSkillsMenuOpen(r.right+4,top,bid);
|
||||
var s=document.getElementById('blockMenuSub');
|
||||
if(s){s.addEventListener('mouseenter',function(){clearTimeout(_bmSubTimer);});
|
||||
s.addEventListener('mouseleave',function(){_blockSubCloseLater();});}
|
||||
}
|
||||
function _blockSubCloseLater(){clearTimeout(_bmSubTimer);_bmSubTimer=setTimeout(function(){var s=document.getElementById('blockMenuSub');if(s&&!s.matches(':hover')){s.remove();_bmSubKind='';}},260);}
|
||||
function _blockMenuClick(ev){
|
||||
var t=ev.target.closest&&ev.target.closest('[data-bm-act]');if(!t)return;
|
||||
var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid');
|
||||
var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid')||_blockMenuBid();
|
||||
if(!act)return;
|
||||
ev.preventDefault();ev.stopPropagation();
|
||||
var E=window.E;if(!E)return;
|
||||
var idx=bid?E.getIdx(bid):-1;
|
||||
if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;}
|
||||
if(act==='submenu'){_blockSubOpen(t.getAttribute('data-sub'),bid,t);return;}
|
||||
if(act==='duplicate'){E.duplicateBlock(idx);}
|
||||
else if(act==='copy-link'){E.copyBlockLink(bid||'');}
|
||||
else if(act==='copy-blocks'){E.copySelectedBlocks();}
|
||||
else if(act==='cut-blocks'){E.cutSelectedBlocks();}
|
||||
else if(act==='paste-blocks'){E.pasteBlocks();}
|
||||
else if(act==='move-to'){_blockMoveToOpen(idx);}
|
||||
else if(act==='delete'){E.deleteSelected(idx);}
|
||||
else if(act==='comment'){E.commentBlock(idx);}
|
||||
else if(act==='suggest'){E.suggestEdit(idx);}
|
||||
else if(act==='ai'){E.askAI(idx);}
|
||||
else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');}
|
||||
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
|
||||
else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'));_blockMenuClose();}
|
||||
else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;}
|
||||
_blockMenuClose();
|
||||
}
|
||||
function _blockMenuItem(act,icon,label,opts){
|
||||
opts=opts||{};
|
||||
var attr=' data-bm-act="'+act+'"';
|
||||
if(opts.bid)attr+=' data-bid="'+_blockAttr(opts.bid)+'"';
|
||||
if(opts.sub)attr+=' data-sub="'+opts.sub+'"';
|
||||
if(opts.colorKey)attr+=' data-color-key="'+opts.colorKey+'" data-color-val="'+_blockAttr(opts.colorVal||'')+'"';
|
||||
if(opts.prompt!=null)attr+=' data-prompt="'+_blockAttr(opts.prompt)+'"';
|
||||
if(opts.type)attr+=' data-type="'+opts.type+'"';
|
||||
if(opts.cols)attr+=' data-cols="'+opts.cols+'"';
|
||||
if(opts.title)attr+=' title="'+_blockAttr(opts.title)+'"';
|
||||
var right=opts.check?'<span class="bm-check">✓</span>'
|
||||
:(opts.sub?'<span class="bm-arrow">▸</span>'
|
||||
:(opts.shortcut?'<span class="bm-hint">'+opts.shortcut+'</span>':''));
|
||||
return '<div class="bm-item'+(opts.danger?' bm-danger':'')+(opts.current?' bm-current':'')+'"'+attr+'>'
|
||||
+(icon!=null?'<span class="bm-ico">'+icon+'</span>':'')+label+right+'</div>';
|
||||
}
|
||||
// Métadonnées du pied de page : auteur / heure de dernière édition / taille du bloc
|
||||
function _blockMenuMeta(b){
|
||||
var E=window.E||{};
|
||||
var who='';
|
||||
try{var n=document.querySelector('.um-name')||document.querySelector('.workspace-name');who=n?(n.textContent||'').trim():'';}catch(e){}
|
||||
var when='';
|
||||
if(E.updatedAt){var d=new Date(E.updatedAt);if(!isNaN(d)){
|
||||
var same=d.toDateString()===new Date().toDateString();
|
||||
var hm=d.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
|
||||
when=(same?'Today at ':(d.toLocaleDateString([])+' at '))+hm;}}
|
||||
var content=(b&&b.content)||'';
|
||||
var trimmed=content.trim();
|
||||
var words=trimmed?trimmed.split(/\s+/).length:0;
|
||||
return '<div class="bm-foot"><div>Last edited by '+esc(who||'you')+'</div>'
|
||||
+(when?'<div>'+esc(when)+'</div>':'')
|
||||
+'<div>'+words+' word'+(words===1?'':'s')+', '+content.length+' character'+(content.length===1?'':'s')+'</div></div>';
|
||||
}
|
||||
function blockMenuOpen(x,y,bid){
|
||||
_blockMenuClose();
|
||||
var E=window.E;if(!E)return;
|
||||
var idx=E.getIdx(bid);if(idx<0)return;
|
||||
var b=E.blocks[idx];if(!b)return;
|
||||
var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.style.cssText='position:fixed;z-index:4000;';
|
||||
var colorsText='',colorsBg='';
|
||||
_BLOCK_COLORS_TEXT.forEach(function(c){colorsText+='<button type="button" data-bm-act="color" data-color-key="color" data-color-val="'+c+'" title="Text color" style="background:'+(c==='default'?'transparent':c)+'" class="bm-swatch'+(c==='default'?' bm-default':'')+'">'+(c==='default'?'A':'')+'</button>';});
|
||||
_BLOCK_COLORS_BG.forEach(function(c){colorsBg+='<button type="button" data-bm-act="color" data-color-key="bgColor" data-color-val="'+c+'" title="Background color" style="background:'+(c==='default'?'transparent':c)+'" class="bm-swatch'+(c==='default'?' bm-default':'')+'"></button>';});
|
||||
d.innerHTML='<div class="bm-item" data-bm-act="turn" data-bid="'+b.id+'"><span class="bm-ico">↩</span>Turn into<span class="bm-arrow">▸</span></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="duplicate" data-bid="'+b.id+'"><span class="bm-ico">▣</span>Duplicate <span class="bm-hint">'+(_rtMac()?'⌘D':'Ctrl+D')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-link" data-bid="'+b.id+'"><span class="bm-ico">🔗</span>Copy link to block</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Copy blocks <span class="bm-hint">'+(_rtMac()?'⌘C':'Ctrl+C')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="cut-blocks" data-bid="'+b.id+'"><span class="bm-ico">✂</span>Cut blocks <span class="bm-hint">'+(_rtMac()?'⌘X':'Ctrl+X')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="paste-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Paste blocks <span class="bm-hint">'+(_rtMac()?'⌘V':'Ctrl+V')+'</span></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="move-to" data-bid="'+b.id+'"><span class="bm-ico">↪</span>Move to…</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Text</div><div class="bm-swatches">'+colorsText+'</div></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Background</div><div class="bm-swatches">'+colorsBg+'</div></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item bm-danger" data-bm-act="delete" data-bid="'+b.id+'"><span class="bm-ico">✕</span>Delete <span class="bm-hint">⌫</span></div>';
|
||||
var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.setAttribute('data-bid',b.id);d.style.cssText='position:fixed;z-index:4000;';
|
||||
var S=_BM_SHORTCUTS;
|
||||
d.innerHTML='<input class="bm-search" type="text" placeholder="Search actions..." autocomplete="off" spellcheck="false">'
|
||||
+'<div class="bm-group">Text</div>'
|
||||
+_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})
|
||||
+_blockMenuItem('submenu','🎨','Color',{sub:'color'})
|
||||
+_blockMenuItem('link','🔗','Copy link to block',{shortcut:S.link})
|
||||
+_blockMenuItem('duplicate','▣','Duplicate',{shortcut:S.duplicate})
|
||||
+_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move})
|
||||
+_blockMenuItem('delete','✕','Delete',{shortcut:S.delete,danger:true})
|
||||
+_blockMenuItem('comment','💬','Comment',{shortcut:S.comment})
|
||||
+_blockMenuItem('suggest','✎','Suggest edits',{shortcut:S.suggest})
|
||||
+_blockMenuItem('ai','✨','Ask AI',{shortcut:S.ai})
|
||||
+_blockMenuItem('submenu','📚','Skills',{sub:'skills'})
|
||||
+_blockMenuMeta(b);
|
||||
d.addEventListener('click',_blockMenuClick);
|
||||
d.addEventListener('input',function(ev){
|
||||
if(!ev.target.classList||!ev.target.classList.contains('bm-search'))return;
|
||||
var q=(ev.target.value||'').toLowerCase();
|
||||
d.querySelectorAll('.bm-item').forEach(function(it){
|
||||
it.style.display=(q&&it.textContent.toLowerCase().indexOf(q)<0)?'none':'';
|
||||
});
|
||||
var g=d.querySelector('.bm-group');if(g)g.style.display=q?'none':'';
|
||||
});
|
||||
d.addEventListener('mouseover',function(ev){
|
||||
var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null;
|
||||
if(it)_blockSubOpen(it.getAttribute('data-sub'),b.id,it);
|
||||
});
|
||||
d.addEventListener('mouseleave',function(){_blockSubCloseLater();});
|
||||
d.addEventListener('contextmenu',function(ev){ev.preventDefault();_blockMenuClose();});
|
||||
document.body.appendChild(d);
|
||||
var r=d.getBoundingClientRect();
|
||||
x=Math.min(Math.max(8,x),window.innerWidth-r.width-8);
|
||||
y=Math.min(Math.max(8,y),window.innerHeight-r.height-8);
|
||||
d.style.left=x+'px';d.style.top=y+'px';
|
||||
var si=d.querySelector('.bm-search');if(si)setTimeout(function(){si.focus();},10);
|
||||
}
|
||||
function _blockTurnMenuOpen(x,y,bid){
|
||||
// Panneau de droite : coquille commune des 3 sous-menus
|
||||
function _blockSubShell(x,y){
|
||||
var m=document.getElementById('blockMenuSub');if(m)m.remove();
|
||||
var d=document.createElement('div');d.id='blockMenuSub';d.className='block-menu block-menu-sub';d.style.cssText='position:fixed;z-index:4001;';
|
||||
var html='';
|
||||
_BLOCK_TURN_TYPES.forEach(function(t){html+='<div class="bm-item" data-bm-act="turn-type" data-type="'+t.id+'" data-bid="'+bid+'">'+t.name+'</div>';});
|
||||
d.innerHTML=html;
|
||||
d.addEventListener('click',_blockMenuClick);
|
||||
document.body.appendChild(d);
|
||||
var r=d.getBoundingClientRect();
|
||||
x=Math.min(Math.max(8,x),window.innerWidth-r.width-8);
|
||||
y=Math.min(Math.max(8,y),window.innerHeight-r.height-8);
|
||||
d.style.left=x+'px';d.style.top=y+'px';
|
||||
return d;
|
||||
}
|
||||
function _blockTurnMenuOpen(x,y,bid){
|
||||
var d=_blockSubShell(x,y);
|
||||
var E=window.E;var cur='';
|
||||
if(E){var i=E.getIdx(bid);if(i>=0&&E.blocks[i])cur=E.blocks[i].type||'';}
|
||||
var html='';
|
||||
_BLOCK_TURN_MENU.forEach(function(t){
|
||||
var on=(t.id===cur&&!t.cols);
|
||||
html+=_blockMenuItem('turn-type',t.icon,t.name,{bid:bid,type:t.id,cols:t.cols,
|
||||
title:t.cols?('Create '+t.cols+' columns of blocks'):'',
|
||||
check:on,current:on});
|
||||
});
|
||||
d.innerHTML=html;
|
||||
}
|
||||
function _blockColorMenuOpen(x,y,bid){
|
||||
var d=_blockSubShell(x,y);
|
||||
var E=window.E;var idx=E?E.getIdx(bid):-1;var b=idx>=0?E.blocks[idx]:null;
|
||||
var curT=(b&&b.style&&b.style.color)||'default';
|
||||
var curB=(b&&b.style&&b.style.bgColor)||'default';
|
||||
var last='';try{last=localStorage.getItem('fdLastBlockColor')||'';}catch(e){}
|
||||
var html='';
|
||||
if(last){
|
||||
var i=last.indexOf('|');var lk=i<0?'color':last.slice(0,i);var lv=last.slice(i+1);
|
||||
var isBg=(lk==='bgColor');
|
||||
var arr=isBg?_BLOCK_COLORS_BG:_BLOCK_COLORS_TEXT;
|
||||
var ni=arr.indexOf(lv);
|
||||
var nm=(ni>=0?_BLOCK_COLOR_NAMES[ni]:'Custom')+(isBg?' background':' text');
|
||||
html+='<div class="bm-group">Last used</div>'
|
||||
+_blockMenuItem('color','<span style="color:'+(lv==='default'?'var(--text-secondary)':lv)+'">A</span>',
|
||||
nm,{bid:bid,colorKey:lk,colorVal:lv,shortcut:_BM_SHORTCUTS.color});
|
||||
}
|
||||
html+='<div class="bm-group">Text color</div>';
|
||||
_BLOCK_COLORS_TEXT.forEach(function(v,n){
|
||||
html+=_blockMenuItem('color','<span style="color:'+(v==='default'?'var(--text-secondary)':v)+'">A</span>',
|
||||
_BLOCK_COLOR_NAMES[n]+' text',{bid:bid,colorKey:'color',colorVal:v,
|
||||
check:v===curT,current:v===curT});
|
||||
});
|
||||
html+='<div class="bm-group">Background color</div>';
|
||||
_BLOCK_COLORS_BG.forEach(function(v,n){
|
||||
html+=_blockMenuItem('color','<span class="bm-dot" style="background:'+(v==='default'?'var(--text-tertiary)':v)+'"></span>',
|
||||
_BLOCK_COLOR_NAMES[n]+' background',{bid:bid,colorKey:'bgColor',colorVal:v,
|
||||
check:v===curB,current:v===curB});
|
||||
});
|
||||
d.innerHTML=html;
|
||||
}
|
||||
function _blockSkillsMenuOpen(x,y,bid){
|
||||
var d=_blockSubShell(x,y);
|
||||
d.innerHTML='<div class="bm-empty">Chargement…</div>';
|
||||
fetch('/api/agent/skills',{credentials:'same-origin'})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(res){
|
||||
if(!document.body.contains(d))return;
|
||||
var list=(res&&res.skills)||[];
|
||||
if(!list.length){d.innerHTML='<div class="bm-empty">Aucune compétence enregistrée — créez-en une dans le panneau Agent.</div>';return;}
|
||||
var html='';
|
||||
list.forEach(function(s){
|
||||
html+=_blockMenuItem('skill','📚',esc(String(s.name||'Skill')),{bid:bid,prompt:s.prompt_template||''});
|
||||
});
|
||||
d.innerHTML=html;
|
||||
})
|
||||
.catch(function(){if(document.body.contains(d))d.innerHTML='<div class="bm-empty">Compétences indisponibles.</div>';});
|
||||
}
|
||||
function _rtMac(){return /Mac|iPhone|iPad/.test(navigator.userAgent);}
|
||||
function _blockMoveToOpen(idx){
|
||||
@@ -1510,10 +1666,37 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
|
||||
// ── v5.10.0: Turn into / couleurs / liens / déplacement ──
|
||||
_blockTargets(idx){const sel=_selIndicesSorted();return(sel.includes(idx))?sel:[idx];},
|
||||
turnInto(idx,type){if(idx<0||idx>=this.blocks.length)return;if(_BLOCK_TURN_TYPES.indexOf(type)<0)return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(type!=='to_do'&&type!=='toggle'&&keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='button'){b.automation_id=0;b.automation_name='';}});
|
||||
turnInto(idx,type,cols){if(idx<0||idx>=this.blocks.length)return;if(!_BLOCK_TURN_TYPES.some(t=>t.id===type))return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='columns'&&cols>=2){b.children=Array.from({length:cols},()=>({id:genId(),type:'paragraph',content:''}));}if(type==='synced'){b.synced_id=0;b._synced_content=[];b._synced_title='';}if(type==='button'){b.automation_id=0;b.automation_name='';}});
|
||||
if(type==='synced')setTimeout(()=>this._pickSyncedBlock(idx),80);
|
||||
this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();
|
||||
setTimeout(()=>{const bid=this.blocks[idx]?this.blocks[idx].id:focusId;if(type==='table'){_tblFocus(bid,0,0);}else{const el=this.getEl(bid);if(el){el.focus();ce(el);}}},60);},
|
||||
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
||||
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});if(v!=null){try{localStorage.setItem('fdLastBlockColor',key+'|'+v);}catch(e){}}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
||||
// ── v7.61 : actions du menu contextuel de bloc ──
|
||||
commentBlock(idx){
|
||||
const b=this.blocks[idx];if(!b)return;const el=this.getEl(b.id);if(!el)return;
|
||||
const r=document.createRange();r.selectNodeContents(el);
|
||||
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
|
||||
this.showCommentBtn();
|
||||
if(!this._commentSel||!this._commentSel.range){this.showToast('Sélectionnez du texte pour commenter','error');return;}
|
||||
this.commentOnSelection();
|
||||
},
|
||||
askAI(idx){const b=this.blocks[idx];if(!b)return;if(window.AIC)window.AIC.open(idx);},
|
||||
suggestEdit(idx){
|
||||
const b=this.blocks[idx];if(!b||!(b.content||'').trim()){this.showToast('Bloc vide','error');return;}
|
||||
this.showToast('FlowDeck AI : suggestion en cours…');
|
||||
this._aiWritingRun('write',idx,{prompt:'Améliore ce bloc (grammaire, clarté, style) en gardant le sens, la langue et le format. Réponds UNIQUEMENT avec le contenu amélioré, sans préambule.\n\nBloc :\n'+b.content},'insert');
|
||||
},
|
||||
runSkill(idx,tpl){
|
||||
const b=this.blocks[idx];if(!b||!(b.content||'').trim()){this.showToast('Bloc vide','error');return;}
|
||||
this.showToast('FlowDeck AI : compétence en cours…');
|
||||
this._aiWritingRun('write',idx,{prompt:String(tpl||'')+'\n\nContenu du bloc :\n'+b.content},'insert');
|
||||
},
|
||||
applyLastColor(idx){
|
||||
let v='';try{v=localStorage.getItem('fdLastBlockColor')||'';}catch(e){}
|
||||
const i=v.indexOf('|');
|
||||
if(i<0){this.showToast('Aucune couleur récente','error');return;}
|
||||
this.setBlockColor(idx,v.slice(0,i),v.slice(i+1));
|
||||
},
|
||||
copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}},
|
||||
deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
// ── v5.11: Copy / Cut / Paste selected blocks ──
|
||||
@@ -1970,6 +2153,12 @@ applyAIBlocks(text){
|
||||
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
|
||||
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
||||
// La requête peut être tapée DANS le bloc (« /numbered list ») si le
|
||||
// focus n'a pas rejoint l'entrée du menu → on la répercute au filtre.
|
||||
if(SM._in&&(e.key.length===1||e.key==='Backspace')){
|
||||
const q=(el.textContent||'').replace(/^\s*\//,'');
|
||||
if(SM._in.value!==q){SM._in.value=q;SM.flt();}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(window.AIAC&&window.AIAC.active){
|
||||
@@ -2486,10 +2675,12 @@ applyAIBlocks(text){
|
||||
else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));
|
||||
else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));
|
||||
else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));
|
||||
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
|
||||
else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));
|
||||
// AVANT les puces : « - [ ] … » commence aussi par « - » →
|
||||
// sinon la case à cocher de l'agent devenait une puce.
|
||||
else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));
|
||||
else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));
|
||||
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
|
||||
else if(/^\d+[.)] /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+[.)] /,'')));
|
||||
else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));
|
||||
else bl.push(this.mkB('paragraph',t));
|
||||
}return bl;},
|
||||
@@ -2559,6 +2750,43 @@ applyAIBlocks(text){
|
||||
// `window.E` survit aux navigations partielles : on exige donc que
|
||||
// l'editeur soit reellement monte (`#_blocksCt`) avant d'agir, sinon
|
||||
// Ctrl+S sauvegarderait un editeur detache depuis une autre page.
|
||||
// ── v7.61 : raccourcis du menu contextuel de bloc ──
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var E = window.E;
|
||||
if (!E || !document.getElementById('_blocksCt')) return;
|
||||
var k = String(e.key || '').toLowerCase();
|
||||
var ctrl = e.ctrlKey || e.metaKey, sh = e.shiftKey, alt = e.altKey;
|
||||
var menu = document.getElementById('blockMenu');
|
||||
var menuBid = menu ? (menu.getAttribute('data-bid') || '') : '';
|
||||
function curIdx(){
|
||||
if (menuBid) { var mi = E.getIdx(menuBid); if (mi >= 0) return mi; }
|
||||
var ab = E.getActiveBlock();
|
||||
return ab && ab.idx >= 0 ? ab.idx : -1;
|
||||
}
|
||||
if (ctrl && !sh && !alt && k === 'j') { e.preventDefault(); var i1 = curIdx(); if (i1 >= 0) E.askAI(i1); return; }
|
||||
if (ctrl && sh && !alt && k === 'h') { e.preventDefault(); var i2 = curIdx(); if (i2 >= 0) E.applyLastColor(i2); return; }
|
||||
if (ctrl && sh && !alt && k === 'm') { e.preventDefault(); var i3 = curIdx(); if (i3 >= 0) E.commentBlock(i3); return; }
|
||||
if (ctrl && sh && alt && k === 'x') { e.preventDefault(); var i4 = curIdx(); if (i4 >= 0) E.suggestEdit(i4); return; }
|
||||
if (alt && sh && !ctrl && k === 'l') { e.preventDefault(); var i5 = curIdx(); if (i5 >= 0) E.copyBlockLink(E.blocks[i5].id); return; }
|
||||
if (ctrl && sh && !alt && k === 'p') { e.preventDefault(); var i6 = curIdx(); if (i6 >= 0) _blockMoveToOpen(i6); return; }
|
||||
// Ctrl+D : déjà géré par la saisie quand le bloc a le focus (pas de doublon)
|
||||
if (ctrl && !sh && !alt && k === 'd') {
|
||||
var ab = E.getActiveBlock();
|
||||
if (ab && document.activeElement === ab.el) return;
|
||||
var idc = ab && ab.idx >= 0 ? ab.idx : (menuBid ? E.getIdx(menuBid) : -1);
|
||||
if (idc >= 0) { e.preventDefault(); E.duplicateBlock(idc); }
|
||||
return;
|
||||
}
|
||||
// Del : supprime le bloc SEULEMENT avec le menu ouvert (jamais pendant la saisie)
|
||||
if (k === 'delete' && !ctrl && !sh && !alt && menu) {
|
||||
var tgt = e.target;
|
||||
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return;
|
||||
var i8 = curIdx();
|
||||
if (i8 >= 0) { e.preventDefault(); E.deleteSelected(i8); }
|
||||
return;
|
||||
}
|
||||
if (k === 'escape' && menu) { _blockMenuClose(); return; }
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
|
||||
if (String(e.key).toLowerCase() !== 's') return;
|
||||
|
||||
@@ -18,6 +18,9 @@ function settingsInit() {
|
||||
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
|
||||
|
||||
activeSection: 'account',
|
||||
// Mobile side-navigation drawer for the settings panel (hamburger in topbar)
|
||||
navOpen: false,
|
||||
closeNavOnMobile() { this.navOpen = false; },
|
||||
allTags: [],
|
||||
newTagName: '',
|
||||
newTagColor: '#787774',
|
||||
|
||||
@@ -89,6 +89,21 @@ def test_llm_client_providers_listed(client):
|
||||
assert name in PROVIDERS
|
||||
|
||||
|
||||
def test_mistral_defaults_are_tier_safe(client):
|
||||
"""La clé Mistral plan basique → 403 « tier_not_allowed » sur mistral-large/pixtral-large.
|
||||
Le modèle par défaut et le premier de la liste du test de connexion doivent
|
||||
être des modèles servis par tous les plans."""
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
|
||||
tier_blocked = {"mistral-large-latest", "pixtral-large-latest"}
|
||||
assert PROVIDERS["mistral"][1] not in tier_blocked
|
||||
assert PROVIDER_MODELS["mistral"][0] not in tier_blocked
|
||||
# le fallback du modèle retiré (PROVIDERS default) est présent dans la liste
|
||||
assert PROVIDERS["mistral"][1] in PROVIDER_MODELS["mistral"]
|
||||
# et la liste des modèles Mistral est validée chat à chaque fetch (403 exclus)
|
||||
from app.services.llm_config import _CHAT_VALIDATED_PROVIDERS
|
||||
assert "mistral" in _CHAT_VALIDATED_PROVIDERS
|
||||
|
||||
|
||||
def test_llm_client_openai_compatible_bases(client):
|
||||
"""Providers that need an OpenAI-compatible surface must point at it.
|
||||
|
||||
|
||||
@@ -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 + 2 livrées → il reste 4 sections « bientôt »
|
||||
assert root.count("disabled:true") == 4
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""v7.53.0 — Menu + : Design System – Canevas (créer / insérer / enregistrer)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
|
||||
|
||||
def test_canvases_list_includes_design_system(client):
|
||||
tpl = {t["key"]: t for t in client.get("/board/api/page-templates").json()["templates"] if t.get("builtin")}
|
||||
assert "design_system" in tpl
|
||||
assert tpl["design_system"]["name"] == "Design System"
|
||||
assert tpl["design_system"]["icon"] == "🎨"
|
||||
|
||||
|
||||
def test_blocks_route_builtin(client):
|
||||
r = client.get("/board/api/page-templates/0/blocks", params={"key": "design_system"})
|
||||
assert r.status_code == 200, r.text
|
||||
blocks = r.json()["blocks"]
|
||||
assert isinstance(blocks, list) and blocks
|
||||
assert blocks[0]["type"] == "heading_1" and blocks[0]["content"] == "Design System"
|
||||
# les builtins sont livrés sans id : le front appelle ensureBlockIds()
|
||||
assert "id" not in blocks[0]
|
||||
|
||||
assert client.get("/board/api/page-templates/0/blocks", params={"key": "nope"}).status_code == 404
|
||||
empty = client.get("/board/api/page-templates/0/blocks", params={"key": "empty"})
|
||||
assert empty.status_code == 200 and empty.json()["blocks"]
|
||||
|
||||
|
||||
def test_blocks_route_user_template(client):
|
||||
blocks = [{"type": "heading_1", "content": "Mon canevas"},
|
||||
{"type": "paragraph", "content": "Contenu."}]
|
||||
created = client.post("/board/api/page-templates", json={"name": "Mon canevas", "blocks": blocks})
|
||||
assert created.status_code == 200, created.text
|
||||
tid = created.json()["id"]
|
||||
|
||||
got = client.get(f"/board/api/page-templates/{tid}/blocks")
|
||||
assert got.status_code == 200
|
||||
assert [b["content"] for b in got.json()["blocks"]] == ["Mon canevas", "Contenu."]
|
||||
|
||||
assert client.get("/board/api/page-templates/999999/blocks").status_code == 404
|
||||
|
||||
|
||||
def test_use_design_system_creates_page(client):
|
||||
r = client.post("/board/api/page-templates/0/use", json={"key": "design_system"})
|
||||
assert r.status_code == 200, r.text
|
||||
page_id = r.json()["id"]
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
assert row["content_format"] == "blocks"
|
||||
blocks = json.loads(row["content"])
|
||||
texts = [b.get("content", "") for b in blocks]
|
||||
assert "Design System" in texts
|
||||
# les ids sont assignés côté serveur (_ensure_block_ids)
|
||||
assert all(b.get("id") for b in blocks)
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
assert "body: JSON.stringify(c.builtin ? {key: c.key} : {})" in src
|
||||
assert "ensureBlockIds" in src
|
||||
@@ -0,0 +1,137 @@
|
||||
"""v7.54.0 — Mémoire de l'agent : toggle par conversation, injection au contexte."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import agent_memory
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
ENGINE = ROOT / "app" / "services" / "agent_engine.py"
|
||||
|
||||
|
||||
def _conv(client) -> tuple[int, dict]:
|
||||
r = client.post("/api/agent/conversations", json={"title": "mémoire"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"], r.json()
|
||||
|
||||
|
||||
def test_migration_created_column_and_table(client):
|
||||
with get_conn() as conn:
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_conversations)")}
|
||||
assert "memory_enabled" in cols
|
||||
tbl = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='agent_memory'"
|
||||
).fetchone()
|
||||
assert tbl is not None
|
||||
|
||||
|
||||
def test_create_conversation_defaults_to_memory_on(client):
|
||||
_, created = _conv(client)
|
||||
assert created["memory_enabled"] == 1 # settings.agent_memory_default
|
||||
|
||||
|
||||
def test_patch_conversation_toggles_memory_persisted(client):
|
||||
cid, _ = _conv(client)
|
||||
assert client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0}).status_code == 200
|
||||
rows = {c["id"]: c for c in client.get("/api/agent/conversations").json()["conversations"]}
|
||||
assert rows[cid]["memory_enabled"] == 0
|
||||
|
||||
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 1})
|
||||
rows = {c["id"]: c for c in client.get("/api/agent/conversations").json()["conversations"]}
|
||||
assert rows[cid]["memory_enabled"] == 1
|
||||
|
||||
|
||||
def test_context_for_on_off_and_persistence(client):
|
||||
cid, _ = _conv(client)
|
||||
agent_memory.remember(cid, "Où en est le design system ?", "Les tokens sont dans design-tokens.css.")
|
||||
ctx = agent_memory.context_for(cid)
|
||||
assert "Mémoire de la conversation" in ctx
|
||||
assert "design-tokens.css" in ctx
|
||||
|
||||
# OFF → plus rien n'est lu ni écrit
|
||||
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0})
|
||||
assert agent_memory.context_for(cid) == ""
|
||||
agent_memory.remember(cid, "Effacé ?", "Ne doit pas apparaître.")
|
||||
assert agent_memory.context_for(cid) == ""
|
||||
|
||||
# ON à nouveau → la mémoire écrite avant le OFF est toujours là (persistée)
|
||||
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 1})
|
||||
assert "design-tokens.css" in agent_memory.context_for(cid)
|
||||
assert "Ne doit pas apparaître" not in agent_memory.context_for(cid)
|
||||
|
||||
|
||||
def test_memory_budget_and_note_lines_bounded(client):
|
||||
cid, _ = _conv(client)
|
||||
for i in range(30):
|
||||
agent_memory.remember(cid, f"question {i} " + "x" * 300, "réponse " + "y" * 900)
|
||||
ctx = agent_memory.context_for(cid)
|
||||
header = "## Mémoire de la conversation (échanges précédents)"
|
||||
assert ctx.startswith(header)
|
||||
assert len(ctx) <= agent_memory.INJECT_BUDGET + len(header) + 60
|
||||
with get_conn() as conn:
|
||||
content = conn.execute(
|
||||
"SELECT content FROM agent_memory WHERE conversation_id=?", (cid,)
|
||||
).fetchone()["content"]
|
||||
assert content.count("\n") + 1 <= agent_memory.NOTE_LINES
|
||||
|
||||
|
||||
async def _drain(engine, cid, objective):
|
||||
"""Consomme le générateur SSE de engine.run()."""
|
||||
return [ev async for ev in engine.run(cid, objective)]
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, text=""):
|
||||
self.text = text
|
||||
self.tool_calls = []
|
||||
self.usage = {}
|
||||
|
||||
|
||||
class _RecorderLLM:
|
||||
def __init__(self):
|
||||
self.last_user = ""
|
||||
|
||||
async def complete(self, messages, *, model=None, tools=None, stream=False):
|
||||
self.last_user = next(m["content"] for m in messages if m["role"] == "user")
|
||||
return _Resp("C'est noté.")
|
||||
|
||||
|
||||
def test_engine_run_injects_memory_when_on(client):
|
||||
"""Le run réel pousse la mémoire dans le prompt utilisateur (toggle ON)."""
|
||||
from app.services.agent_engine import AgentEngine
|
||||
|
||||
cid, _ = _conv(client)
|
||||
agent_memory.remember(cid, "Priorité du sprint", "Livrer le menu + en priorité.")
|
||||
fake = _RecorderLLM()
|
||||
engine = AgentEngine(1, llm=fake)
|
||||
asyncio.run(_drain(engine, cid, "Quelle est la priorité ?"))
|
||||
assert "Mémoire de la conversation" in fake.last_user
|
||||
assert "menu +" in fake.last_user
|
||||
|
||||
|
||||
def test_engine_run_omits_memory_when_off(client):
|
||||
from app.services.agent_engine import AgentEngine
|
||||
|
||||
cid, _ = _conv(client)
|
||||
agent_memory.remember(cid, "Secret", "ne pas injecter")
|
||||
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0})
|
||||
fake = _RecorderLLM()
|
||||
engine = AgentEngine(1, llm=fake)
|
||||
asyncio.run(_drain(engine, cid, "Bonjour"))
|
||||
assert "Mémoire de la conversation" not in fake.last_user
|
||||
assert "ne pas injecter" not in fake.last_user
|
||||
|
||||
|
||||
def test_memory_menu_toggle_wired():
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
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
|
||||
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")
|
||||
assert "agent_memory.context_for(" in engine
|
||||
assert "agent_memory.remember(" in engine
|
||||
@@ -0,0 +1,172 @@
|
||||
"""v7.55.0 — Connecteurs de l'agent : catalogue, CRUD, SSRF, statut, outil LLM."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import connectors
|
||||
from app.services.sso_provisioning import decrypt_secret
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
|
||||
|
||||
PUBLIC_URL = "https://example.com/api"
|
||||
|
||||
|
||||
def _create(client, **kw):
|
||||
body = {"name": "Conn perso", "url": PUBLIC_URL, "secret": "sk-live-abc", **kw}
|
||||
r = client.post("/api/agent/connectors", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_native_connectors_always_listed(client):
|
||||
rows = client.get("/api/agent/connectors").json()["connectors"]
|
||||
native = {r["kind"]: r for r in rows if r["builtin"]}
|
||||
assert set(native) == {"gitea", "github", "web", "google", "ms365"}
|
||||
for r in native.values():
|
||||
assert r["id"] is None
|
||||
assert r["status"] in ("ok", "missing")
|
||||
assert r["enabled"] is True
|
||||
assert native["web"]["status"] == "ok"
|
||||
# les connecteurs OAuth n'existent que si configurés (client_id/secret)
|
||||
assert native["google"]["oauth"] is True
|
||||
assert native["google"]["status"] == "missing"
|
||||
|
||||
|
||||
def test_create_custom_connector_never_returns_secret(client):
|
||||
row = _create(client)
|
||||
assert row["kind"] == "custom" and row["builtin"] is False
|
||||
assert row["has_secret"] is True
|
||||
assert "sk-live-abc" not in str(row) # jamais en clair dans la réponse
|
||||
with get_conn() as conn:
|
||||
stored = conn.execute(
|
||||
"SELECT secret_encrypted FROM agent_connectors WHERE id=?", (row["id"],)
|
||||
).fetchone()["secret_encrypted"]
|
||||
assert stored and "sk-live-abc" not in stored # chiffré (Fernet)
|
||||
assert decrypt_secret(stored) == "sk-live-abc"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"http://localhost/secret",
|
||||
"http://127.0.0.1:8080/admin",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"ftp://exemple.com/api",
|
||||
"file:///etc/passwd",
|
||||
])
|
||||
def test_create_rejects_non_public_urls(client, bad):
|
||||
r = client.post("/api/agent/connectors", json={"name": "interne", "url": bad})
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_patch_toggle_and_delete(client):
|
||||
row = _create(client)
|
||||
cid = row["id"]
|
||||
off = client.patch(f"/api/agent/connectors/{cid}", json={"enabled": False})
|
||||
assert off.status_code == 200 and off.json()["enabled"] is False
|
||||
|
||||
listed = {r["id"]: r for r in client.get("/api/agent/connectors").json()["connectors"]}
|
||||
assert listed[cid]["enabled"] is False
|
||||
|
||||
assert client.delete(f"/api/agent/connectors/{cid}").status_code == 200
|
||||
assert client.delete(f"/api/agent/connectors/{cid}").status_code == 404
|
||||
assert client.patch(f"/api/agent/connectors/{cid}", json={"enabled": True}).status_code == 404
|
||||
|
||||
|
||||
def test_connectors_require_session(client):
|
||||
anon_csrf(client)
|
||||
assert client.get("/api/agent/connectors").status_code == 401
|
||||
assert client.post("/api/agent/connectors",
|
||||
json={"name": "x", "url": PUBLIC_URL}).status_code == 401
|
||||
|
||||
|
||||
def test_probe_persists_status(client, monkeypatch):
|
||||
row = _create(client)
|
||||
cid = row["id"]
|
||||
|
||||
async def ok_get(url, headers=None):
|
||||
assert url.startswith("https://example.com") # URL du connecteur
|
||||
assert (headers or {}).get("Authorization") == "Bearer sk-live-abc" # clé déchiffrée
|
||||
return 200, "{}"
|
||||
|
||||
monkeypatch.setattr(connectors, "_get", ok_get)
|
||||
res = _probe(client, str(cid))
|
||||
assert res["status"] == "ok", res
|
||||
with get_conn() as conn:
|
||||
st = conn.execute("SELECT status FROM agent_connectors WHERE id=?", (cid,)).fetchone()
|
||||
assert st["status"] == "ok"
|
||||
|
||||
|
||||
def _probe(client, target):
|
||||
r = client.post("/api/agent/connectors/probe", json={"connector": target})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_probe_error_is_persisted(client, monkeypatch):
|
||||
row = _create(client)
|
||||
cid = row["id"]
|
||||
|
||||
async def boom(url, headers=None):
|
||||
raise ValueError("Hôte non autorisé")
|
||||
|
||||
monkeypatch.setattr(connectors, "_get", boom)
|
||||
res = _probe(client, str(cid))
|
||||
assert res["status"] == "error"
|
||||
assert "Hôte non autorisé" in res["detail"]
|
||||
with get_conn() as conn:
|
||||
st = conn.execute("SELECT status, detail FROM agent_connectors WHERE id=?",
|
||||
(cid,)).fetchone()
|
||||
assert st["status"] == "error"
|
||||
|
||||
|
||||
def test_connector_fetch_tool_registered_and_works(client, monkeypatch):
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
|
||||
reg = ToolRegistry()
|
||||
schema = {t["name"]: t for t in reg.schema()}
|
||||
assert "connector_fetch" in schema
|
||||
assert schema["connector_fetch"]["parameters"]["required"] == ["connector"]
|
||||
|
||||
row = _create(client)
|
||||
|
||||
async def ok_get(url, headers=None):
|
||||
return 200, '{"ok": true, "source": "exemple"}'
|
||||
|
||||
monkeypatch.setattr(connectors, "_get", ok_get)
|
||||
res = asyncio.run(reg.execute("connector_fetch", {"connector": str(row["id"]),
|
||||
"path": "/status"}))
|
||||
assert res.status == "success"
|
||||
assert "exemple" in res.data["text"]
|
||||
|
||||
# connecteur désactivé → refusé
|
||||
client.patch(f"/api/agent/connectors/{row['id']}", json={"enabled": False})
|
||||
res2 = asyncio.run(reg.execute("connector_fetch", {"connector": str(row["id"])}))
|
||||
assert res2.status == "error"
|
||||
assert "désactivé" in res2.message.lower()
|
||||
|
||||
# connecteur inconnu → erreur outil (pas d'exception qui casse le run)
|
||||
res3 = asyncio.run(reg.execute("connector_fetch", {"connector": "999999"}))
|
||||
assert res3.status == "error"
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
for fn in ("fetchConnectors()", "connectorCreate()", "connectorProbe()",
|
||||
"connectorToggle()", "connectorDelete()"):
|
||||
assert fn in src, fn
|
||||
html = PANEL_HTML.read_text(encoding="utf-8")
|
||||
assert "plusSection==='connector-new'" in html
|
||||
assert 'type="password"' in html # la clé n'est pas en clair à l'écran
|
||||
resp = client.get("/accounts")
|
||||
assert resp.status_code == 200 and "connectorForm" in resp.text
|
||||
@@ -0,0 +1,231 @@
|
||||
"""v7.56.0 — Connecteurs OAuth Google / Microsoft 365 (PKCE, tokens, refresh)."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import oauth_connectors
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configured(monkeypatch):
|
||||
"""Clients OAuth fictifs (aucune clé réelle nécessaire pour les tests)."""
|
||||
from app.config import settings
|
||||
monkeypatch.setattr(settings, "google_client_id", "gid-test")
|
||||
monkeypatch.setattr(settings, "google_client_secret", "gsec-test")
|
||||
monkeypatch.setattr(settings, "ms_client_id", "mid-test")
|
||||
monkeypatch.setattr(settings, "ms_client_secret", "msec-test")
|
||||
monkeypatch.setattr(settings, "app_base_url", "https://flowdeck.example")
|
||||
return settings
|
||||
|
||||
|
||||
def _begin(client, kind="google"):
|
||||
r = client.post(f"/api/agent/connectors/oauth/{kind}/authorize")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_authorize_url_has_pkce_scope_and_state(client, configured):
|
||||
data = _begin(client)
|
||||
url = data["url"]
|
||||
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth?")
|
||||
assert "client_id=gid-test" in url
|
||||
assert "code_challenge=" in url and "code_challenge_method=S256" in url
|
||||
assert "drive.readonly" in url and "gmail.readonly" in url and "calendar.readonly" in url
|
||||
assert "state=" in url
|
||||
assert "redirect_uri=" in url and "api%2Fagent%2Fconnectors%2Foauth%2Fgoogle%2Fcallback" in url
|
||||
# les cookies d'état partent avec la réponse (10 min, HttpOnly)
|
||||
for key in ("fd_oauth_state_google", "fd_oauth_verifier_google", "fd_oauth_next_google"):
|
||||
assert key in client.cookies
|
||||
|
||||
|
||||
def test_authorize_without_client_config_is_400(client):
|
||||
r = client.post("/api/agent/connectors/oauth/google/authorize")
|
||||
assert r.status_code == 400
|
||||
assert "non configuré" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_callback_saves_tokens_encrypted_and_redirects(client, configured, monkeypatch):
|
||||
_begin(client)
|
||||
state = client.cookies["fd_oauth_state_google"]
|
||||
|
||||
async def fake_post(url, form=None, **kw):
|
||||
assert url == "https://oauth2.googleapis.com/token"
|
||||
assert form["grant_type"] == "authorization_code"
|
||||
assert form["code"] == "abc123"
|
||||
assert form["code_verifier"] == client.cookies["fd_oauth_verifier_google"]
|
||||
return {"access_token": "at-1", "refresh_token": "rt-1",
|
||||
"expires_in": 3600, "scope": "openid email"}
|
||||
|
||||
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
|
||||
resp = client.get(
|
||||
"/api/agent/connectors/oauth/google/callback",
|
||||
params={"code": "abc123", "state": state},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "oauth=connected" in resp.headers["location"]
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT tokens_enc FROM connector_tokens").fetchone()
|
||||
assert row and "at-1" not in row["tokens_enc"] # chiffré
|
||||
assert oauth_connectors.tokens("google", 1)["access_token"] == "at-1"
|
||||
|
||||
status = client.get("/api/agent/connectors/oauth/google/status").json()
|
||||
assert status["connected"] is True and status["configured"] is True
|
||||
|
||||
|
||||
def test_callback_rejects_state_mismatch(client, configured, monkeypatch):
|
||||
called = []
|
||||
|
||||
async def fake_post(url, form=None, **kw):
|
||||
called.append(form)
|
||||
return {"access_token": "at"}
|
||||
|
||||
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
|
||||
resp = client.get(
|
||||
"/api/agent/connectors/oauth/google/callback",
|
||||
params={"code": "abc", "state": "forged"}, # != cookie
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302 and "oauth_error=state" in resp.headers["location"]
|
||||
assert called == []
|
||||
assert not oauth_connectors.is_connected("google", 1)
|
||||
|
||||
|
||||
def test_callback_without_session_redirects(client, configured, monkeypatch):
|
||||
_begin(client)
|
||||
state = client.cookies["fd_oauth_state_google"]
|
||||
|
||||
async def fake_post(url, form=None, **kw):
|
||||
return {"access_token": "at"}
|
||||
|
||||
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
|
||||
# on retire la SEULE session (anon_csrf effacerait aussi le cookie d'état OAuth)
|
||||
client.cookies.delete("flowdeck_session")
|
||||
client.auth = None
|
||||
resp = client.get(
|
||||
"/api/agent/connectors/oauth/google/callback",
|
||||
params={"code": "abc", "state": state},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302 and "oauth_error=session" in resp.headers["location"]
|
||||
|
||||
|
||||
def test_refresh_token_flow(client, configured, monkeypatch):
|
||||
oauth_connectors.save("google", 1, {
|
||||
"access_token": "old", "refresh_token": "rt-keep",
|
||||
"expires_at": int(time.time()) - 10, "scope": "openid",
|
||||
})
|
||||
calls = []
|
||||
|
||||
async def fake_post(url, form=None, **kw):
|
||||
calls.append(form)
|
||||
return {"access_token": "new", "expires_in": 3600} # pas de refresh_token
|
||||
|
||||
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
|
||||
access = asyncio.run(oauth_connectors.access_token("google", 1))
|
||||
assert access == "new"
|
||||
assert calls[0]["grant_type"] == "refresh_token"
|
||||
assert calls[0]["refresh_token"] == "rt-keep"
|
||||
stored = oauth_connectors.tokens("google", 1)
|
||||
assert stored["refresh_token"] == "rt-keep" # conservé si absent de la réponse
|
||||
assert stored["expires_at"] > time.time()
|
||||
|
||||
|
||||
def test_api_get_bearer_and_refuses_absolute_url(client, configured, monkeypatch):
|
||||
oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "",
|
||||
"expires_at": int(time.time()) + 3600,
|
||||
"scope": "openid"})
|
||||
seen = []
|
||||
|
||||
async def fake_get(url, headers=None):
|
||||
seen.append((url, headers))
|
||||
return 200, '{"emails": 3}'
|
||||
|
||||
monkeypatch.setattr(oauth_connectors, "_api_get", fake_get)
|
||||
res = asyncio.run(oauth_connectors.api_get("google", 1, "/gmail/v1/users/me/profile"))
|
||||
assert res["status"] == "ok"
|
||||
assert seen[0][0].startswith("https://www.googleapis.com/gmail/v1/")
|
||||
assert seen[0][1]["Authorization"] == "Bearer at"
|
||||
|
||||
# une URL absolue dans `path` ne doit pas dévier (hôte fixe + validation)
|
||||
res2 = asyncio.run(oauth_connectors.api_get("google", 1, "https://evil.example/x"))
|
||||
assert res2["status"] == "error"
|
||||
assert len(seen) == 1 # aucun appel réseau
|
||||
|
||||
|
||||
def test_api_get_requires_connection(client, configured):
|
||||
res = asyncio.run(oauth_connectors.api_get("google", 1, "/me"))
|
||||
assert res["status"] == "error" and "non connecté" in res["text"]
|
||||
|
||||
|
||||
def test_disconnect_clears_tokens(client, configured):
|
||||
oauth_connectors.save("ms365", 1, {"access_token": "at", "expires_at": 1, "scope": ""})
|
||||
assert oauth_connectors.is_connected("ms365", 1)
|
||||
r = client.post("/api/agent/connectors/oauth/ms365/disconnect")
|
||||
assert r.status_code == 200 and r.json()["status"] == "disconnected"
|
||||
assert not oauth_connectors.is_connected("ms365", 1)
|
||||
|
||||
|
||||
def test_catalogue_marks_oauth_connectors(client, configured):
|
||||
rows = client.get("/api/agent/connectors").json()["connectors"]
|
||||
by_kind = {r["kind"]: r for r in rows if r["builtin"]}
|
||||
assert "google" in by_kind and "ms365" in by_kind
|
||||
assert by_kind["google"]["oauth"] is True
|
||||
assert by_kind["google"]["status"] == "missing" # configuré mais pas connecté
|
||||
assert "non connecté" in by_kind["google"]["detail"]
|
||||
|
||||
oauth_connectors.save("google", 1, {"access_token": "at", "expires_at": 9_999_999_999,
|
||||
"scope": "openid email drive"})
|
||||
rows = client.get("/api/agent/connectors").json()["connectors"]
|
||||
g = {r["kind"]: r for r in rows if r["builtin"]}["google"]
|
||||
assert g["status"] == "ok" and "connecté" in g["detail"]
|
||||
|
||||
|
||||
def test_tool_dispatches_to_oauth_api(client, configured, monkeypatch):
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
|
||||
oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "",
|
||||
"expires_at": int(time.time()) + 3600,
|
||||
"scope": "openid"})
|
||||
|
||||
async def fake_get(url, headers=None):
|
||||
assert url.startswith("https://www.googleapis.com/")
|
||||
assert headers["Authorization"] == "Bearer at"
|
||||
return 200, '{"name": "Bruno"}'
|
||||
|
||||
monkeypatch.setattr(oauth_connectors, "_api_get", fake_get)
|
||||
res = asyncio.run(ToolRegistry().execute(
|
||||
"connector_fetch", {"connector": "google", "path": "/oauth2/v3/userinfo"},
|
||||
user_id=1))
|
||||
assert res.status == "success"
|
||||
assert "Bruno" in res.data["text"]
|
||||
|
||||
|
||||
def test_oauth_routes_require_session(client):
|
||||
anon_csrf(client)
|
||||
assert client.get("/api/agent/connectors/oauth/google/status").status_code == 401
|
||||
assert client.post("/api/agent/connectors/oauth/google/authorize").status_code == 401
|
||||
assert client.post("/api/agent/connectors/oauth/google/disconnect").status_code == 401
|
||||
# kind inconnu → 404 même authentifié
|
||||
assert client.post("/api/agent/connectors/oauth/dropbox/authorize").status_code == 404
|
||||
|
||||
|
||||
def test_oauth_menu_wired():
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
for token in ("'connector-connect'", "'connector-disconnect'",
|
||||
"connectorConnect()", "connectorDisconnect()",
|
||||
"qs.get('oauth_error')", "Connecteur connecté."):
|
||||
assert token in src, token
|
||||
assert "c.oauth" in src
|
||||
# le fournisseur revient avec `oauth=connected` (côté route)
|
||||
router = (ROOT / "app" / "routers" / "agent.py").read_text(encoding="utf-8")
|
||||
assert "oauth=connected" in router
|
||||
@@ -0,0 +1,209 @@
|
||||
"""v7.57.0 — Discord / Telegram (presets) + serveurs MCP (outils dynamiques)."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import connectors, mcp_client
|
||||
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"
|
||||
|
||||
|
||||
def _create(client, **kw):
|
||||
body = {"name": "Conn", "url": "https://example.com/v1", "secret": "sk-1", **kw}
|
||||
r = client.post("/api/agent/connectors", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ── Presets Discord / Telegram ──────────────────────────────────────────────
|
||||
|
||||
def test_discord_preset_uses_bot_auth(client):
|
||||
row = _create(client, name="Discord", url="https://discord.com/api/v10",
|
||||
auth="bot", kind="discord")
|
||||
assert row["kind"] == "discord" and row["auth"] == "bot"
|
||||
assert connectors._headers(row["id"])["Authorization"].startswith("Bot ")
|
||||
|
||||
|
||||
def test_telegram_secret_lives_in_url_not_in_db(client, monkeypatch):
|
||||
row = _create(client, name="Telegram", kind="telegram", auth="none",
|
||||
url="https://api.telegram.org/bot{secret}", secret="123456:abc")
|
||||
with get_conn() as conn:
|
||||
stored = conn.execute("SELECT url FROM agent_connectors WHERE id=?",
|
||||
(row["id"],)).fetchone()["url"]
|
||||
assert "{secret}" in stored and "123456" not in stored # jamais en clair
|
||||
|
||||
seen = []
|
||||
|
||||
async def fake_get(url, headers=None):
|
||||
seen.append((url, headers))
|
||||
return 200, '{"ok": true}'
|
||||
|
||||
monkeypatch.setattr(connectors, "_get", fake_get)
|
||||
asyncio.run(connectors.connector_fetch(str(row["id"]), path="getMe"))
|
||||
assert seen[0][0] == "https://api.telegram.org/bot123456:abc/getMe"
|
||||
assert "Authorization" not in seen[0][1] # auth=none
|
||||
|
||||
|
||||
def test_secret_placeholder_requires_key_and_valid_kinds(client):
|
||||
no_key = client.post("/api/agent/connectors",
|
||||
json={"name": "TG", "url": "https://api.telegram.org/bot{secret}"})
|
||||
assert no_key.status_code == 400 and "secret" in no_key.json()["detail"]
|
||||
bad_kind = client.post("/api/agent/connectors",
|
||||
json={"name": "X", "url": "https://example.com/x", "kind": "slack"})
|
||||
assert bad_kind.status_code == 400 and "kind" in bad_kind.json()["detail"]
|
||||
bad_auth = client.post("/api/agent/connectors",
|
||||
json={"name": "X", "url": "https://example.com/x", "auth": "digest"})
|
||||
assert bad_auth.status_code == 400 and "auth" in bad_auth.json()["detail"]
|
||||
|
||||
|
||||
# ── MCP : handshake, cache, outils dynamiques ───────────────────────────────
|
||||
|
||||
def _mcp_server(client) -> int:
|
||||
return _create(client, name="Demo Server", kind="mcp", url="https://example.com/mcp")["id"]
|
||||
|
||||
|
||||
def _fake_rpc_factory(calls):
|
||||
async def fake_rpc(url, payload, headers=None):
|
||||
assert url.startswith("https://example.com/mcp") # SSRF: hôte public fixe
|
||||
calls.append(payload.get("method"))
|
||||
method = payload.get("method")
|
||||
if method == "initialize":
|
||||
return {"result": {"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {"name": "demo"}}}
|
||||
if method == "tools/list":
|
||||
return {"result": {"tools": [
|
||||
{"name": "search_docs", "description": "Cherche dans les notes",
|
||||
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}},
|
||||
"required": ["q"]}},
|
||||
{"name": "Echo", "description": "Répète"},
|
||||
]}}
|
||||
if method == "tools/call":
|
||||
return {"result": {"content": [{"type": "text", "text": "résultat utile"}]}}
|
||||
return {}
|
||||
return fake_rpc
|
||||
|
||||
|
||||
def test_mcp_probe_handshakes_and_caches_tools(client, monkeypatch):
|
||||
sid = _mcp_server(client)
|
||||
calls = []
|
||||
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory(calls))
|
||||
|
||||
res = asyncio.run(connectors.probe(str(sid)))
|
||||
assert res["status"] == "ok", res
|
||||
assert "2 outil(s)" in res["detail"]
|
||||
assert calls == ["initialize", "notifications/initialized", "tools/list"]
|
||||
|
||||
with get_conn() as conn:
|
||||
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
|
||||
(sid,)).fetchone()["tools_json"]
|
||||
assert "mcp_demo_server_search_docs" in tools # nom LLM calculé
|
||||
rows = client.get("/api/agent/connectors").json()["connectors"]
|
||||
me = {r["id"]: r for r in rows if r["id"] == sid}[sid]
|
||||
assert me["kind"] == "mcp" and me["tools_count"] == 2
|
||||
|
||||
|
||||
def test_dynamic_tools_reach_the_llm_schema_and_execute(client, monkeypatch):
|
||||
sid = _mcp_server(client)
|
||||
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
|
||||
asyncio.run(connectors.probe(str(sid)))
|
||||
|
||||
reg = ToolRegistry()
|
||||
schema = {t["name"]: t for t in reg.schema()}
|
||||
assert "mcp_demo_server_search_docs" in schema
|
||||
assert schema["mcp_demo_server_search_docs"]["parameters"]["required"] == ["q"]
|
||||
assert "mcp_demo_server_echo" in schema
|
||||
|
||||
res = asyncio.run(reg.execute("mcp_demo_server_search_docs", {"q": "notes"}))
|
||||
assert res.status == "success"
|
||||
assert "résultat utile" in res.data["text"]
|
||||
|
||||
|
||||
def test_disabled_mcp_server_hides_its_tools(client, monkeypatch):
|
||||
sid = _mcp_server(client)
|
||||
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
|
||||
asyncio.run(connectors.probe(str(sid)))
|
||||
assert "mcp_demo_server_echo" in {t["name"] for t in ToolRegistry().schema()}
|
||||
|
||||
client.patch(f"/api/agent/connectors/{sid}", json={"enabled": False})
|
||||
assert "mcp_demo_server_echo" not in {t["name"] for t in ToolRegistry().schema()}
|
||||
# hors du registre → refus explicite plutôt qu'un appel réseau raté
|
||||
res = asyncio.run(ToolRegistry().execute("mcp_demo_server_echo", {}))
|
||||
assert res.status == "error" and "inconnu" in res.message.lower()
|
||||
|
||||
|
||||
def test_mcp_rpc_errors_become_probe_errors(client, monkeypatch):
|
||||
sid = _mcp_server(client)
|
||||
|
||||
async def bad_rpc(url, payload, headers=None):
|
||||
if payload.get("method") == "initialize":
|
||||
raise ValueError("MCP -32000 : handshake refusé")
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_rpc", bad_rpc)
|
||||
res = asyncio.run(connectors.probe(str(sid)))
|
||||
assert res["status"] == "error" and "handshake" in res["detail"]
|
||||
# les outils restent absents : pas de cache partiel
|
||||
with get_conn() as conn:
|
||||
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
|
||||
(sid,)).fetchone()["tools_json"]
|
||||
assert tools == "[]"
|
||||
|
||||
|
||||
def test_parse_body_handles_sse_and_rpc_errors():
|
||||
sse = mcp_client.parse_body(
|
||||
"text/event-stream; charset=utf-8",
|
||||
": keep-alive\nevent: message\ndata: {\"result\": {\"ok\": 1}}\n\n", 200)
|
||||
assert sse == {"result": {"ok": 1}}
|
||||
assert mcp_client.parse_body("application/json", '{"result": {}}', 200) == {"result": {}}
|
||||
with pytest.raises(ValueError, match="illisible"):
|
||||
mcp_client.parse_body("text/html", "<html>gateway</html>", 502)
|
||||
with pytest.raises(ValueError, match="-32601"):
|
||||
mcp_client.parse_body("application/json",
|
||||
'{"error": {"code": -32601, "message": "Method not found"}}', 200)
|
||||
|
||||
|
||||
def test_tool_name_slug():
|
||||
assert mcp_client.tool_name("Demo Server", "search-docs") == "mcp_demo_server_search_docs"
|
||||
assert mcp_client.tool_name("!!", "Echo") == "mcp_echo"
|
||||
|
||||
|
||||
# ── Teams + câblage ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_ms365_scopes_include_teams_messages():
|
||||
from app.services.oauth_connectors import PROVIDERS
|
||||
assert "ChannelMessage.Read.All" in PROVIDERS["ms365"]["scopes"]
|
||||
assert "Files.Read" in PROVIDERS["ms365"]["scopes"]
|
||||
|
||||
|
||||
def test_connectors_menu_wired_for_presets(client):
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
for token in ("connectorPreset()", "kind: f.kind || 'custom'",
|
||||
"auth: f.auth || 'bearer'",
|
||||
"https://discord.com/api/v10",
|
||||
"https://api.telegram.org/bot{secret}", "presets[f.kind]"):
|
||||
assert token in src, token
|
||||
html = PANEL_HTML.read_text(encoding="utf-8")
|
||||
assert "fd-plus-cn-kind" in html
|
||||
for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
|
||||
'connectorForm.kind'):
|
||||
assert token in html, token
|
||||
# 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") == 0
|
||||
resp = client.get("/accounts")
|
||||
assert resp.status_code == 200 and "connectorPreset" in resp.text
|
||||
|
||||
|
||||
def test_mcp_routes_require_session(client):
|
||||
anon_csrf(client)
|
||||
assert client.get("/api/agent/connectors").status_code == 401
|
||||
assert client.post("/api/agent/connectors",
|
||||
json={"name": "x", "url": "https://example.com/x",
|
||||
"kind": "mcp"}).status_code == 401
|
||||
@@ -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
|
||||
@@ -0,0 +1,100 @@
|
||||
"""7.59.2 — corrections : bouton ✕ de l'aide, « Insert below » qui perd le
|
||||
format, listes numérotées invisibles (agent + /numbered list)."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
HELP_HTML = ROOT / "app" / "templates" / "help.html"
|
||||
HELP_JS = ROOT / "static" / "js" / "help.js"
|
||||
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
|
||||
APP_CSS = ROOT / "static" / "css" / "app.css"
|
||||
SETTINGS_CSS = ROOT / "static" / "css" / "settings.css"
|
||||
|
||||
|
||||
# ── 1. Aide : fermeture en haut à droite (mobile compris) ───────────────────
|
||||
|
||||
def test_help_page_has_close_button():
|
||||
html = HELP_HTML.read_text(encoding="utf-8")
|
||||
js = HELP_JS.read_text(encoding="utf-8")
|
||||
css = SETTINGS_CSS.read_text(encoding="utf-8")
|
||||
assert 'class="settings-close"' in html # bouton ✕ présent
|
||||
assert '@click="close()"' in html
|
||||
assert "close()" in js and "history.back()" in js # méthode réelle
|
||||
assert ".settings-close{" in css # style partagé (32/44px)
|
||||
assert "position:relative" in css.split(".settings-panel{")[1][:40]
|
||||
|
||||
|
||||
# ── 2. Insert below : on insère le MARKDOWN, pas le texte du HTML rendu ─────
|
||||
|
||||
def test_ai_insert_below_keeps_source_markdown():
|
||||
src = EDITOR_JS.read_text(encoding="utf-8")
|
||||
assert "self._md=String(text||'')" in src # source conservée
|
||||
assert "String(this._md||'').trim()||this._resultText()" in src # md d'abord
|
||||
assert src.count("this._md='';") >= 2 # close() + _err()
|
||||
# l'ancien chemin (texte extrait du DOM rendu) n'est plus que le repli
|
||||
assert "_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result')" in src
|
||||
|
||||
|
||||
# ── 3. Listes numérotées : numéros réellement affichés ──────────────────────
|
||||
|
||||
def test_numbered_list_renders_its_number():
|
||||
src = EDITOR_JS.read_text(encoding="utf-8")
|
||||
css = APP_CSS.read_text(encoding="utf-8")
|
||||
assert "let numAttr=''" in src and "data-num=" in src # rangée contiguë → rang
|
||||
assert ".block-numbered[data-num]::before" in css
|
||||
assert 'content: attr(data-num) ". "' in css
|
||||
# md2b accepte « 1. » et « 1) » (CommonMark) pour l'insertion agent
|
||||
assert r"/^\d+[.)] /" in src
|
||||
|
||||
|
||||
def test_slash_query_typed_in_block_is_synced_to_filter():
|
||||
src = EDITOR_JS.read_text(encoding="utf-8")
|
||||
# « /numbered list » tapé dans le bloc → répercuté dans l'entrée du menu
|
||||
assert "SM._in.value=q" in src
|
||||
assert "SM.flt();" in src
|
||||
|
||||
|
||||
# ── md2b : test comportemental sur la source réelle (node) ──────────────────
|
||||
|
||||
def _md2b_source() -> str:
|
||||
src = EDITOR_JS.read_text(encoding="utf-8")
|
||||
i = src.index("md2b(md){")
|
||||
j = src.index("return bl;},", i) + len("return bl;},")
|
||||
snippet = src[i:j].rstrip().rstrip(",") # « md2b(md){…} »
|
||||
return snippet
|
||||
|
||||
|
||||
def test_md2b_converts_numbered_lists_for_real():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node absent : test comportemental md2b ignoré")
|
||||
md = ("# Titre\n"
|
||||
"1. premier\n"
|
||||
"2. second\n"
|
||||
"3) troisième\n"
|
||||
"- puce\n"
|
||||
"- [ ] à faire\n"
|
||||
"> citation\n")
|
||||
script = (
|
||||
"function _tblIsData(){return false;}\n"
|
||||
"function _tblSplitLine(){return [];}\n"
|
||||
"const editor={mkB(t,c,e){const o={type:t,content:c||''};"
|
||||
"if(e)Object.assign(o,e);return o;},\n"
|
||||
f"{_md2b_source()}}};\n"
|
||||
f"console.log(JSON.stringify(editor.md2b({json.dumps(md)})));\n"
|
||||
)
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||||
timeout=30, encoding="utf-8")
|
||||
assert out.returncode == 0, out.stderr
|
||||
blocks = json.loads(out.stdout)
|
||||
assert [b["type"] for b in blocks] == [
|
||||
"heading_1",
|
||||
"numbered_list", "numbered_list", "numbered_list", # 1. 2. 3)
|
||||
"bulleted_list", "to_do", "quote",
|
||||
]
|
||||
assert [b["content"] for b in blocks[1:4]] == ["premier", "second", "troisième"]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""7.59.3 — « Turn into » du menu contextuel : garde cassée + texte conservé."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
|
||||
SRC = EDITOR_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _slice(start: str, end: str) -> str:
|
||||
i = SRC.index(start)
|
||||
j = SRC.index(end, i)
|
||||
return SRC[i:j].rstrip()
|
||||
|
||||
|
||||
def test_turn_into_guard_matches_objects_not_strings():
|
||||
# BUG : indexOf() sur un tableau d'objets → toujours -1 → retour immédiat.
|
||||
assert "_BLOCK_TURN_TYPES.indexOf(type)" not in SRC
|
||||
assert "_BLOCK_TURN_TYPES.some(t=>t.id===type)" in SRC
|
||||
|
||||
|
||||
def test_turn_into_menu_wiring():
|
||||
# menu ⋮⋮ → « Turn into » → sous-menu → choix de type
|
||||
assert "_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})" in SRC
|
||||
assert "'turn-type'" in SRC # construit par _blockMenuItem
|
||||
assert "act==='turn-type'" in SRC
|
||||
assert "_blockTurnMenuOpen(" in SRC
|
||||
|
||||
|
||||
def test_turn_into_keeps_text_for_to_do_and_toggle():
|
||||
# Convertir un texte en To-do / Toggle ne doit plus effacer le contenu.
|
||||
line = [ln for ln in SRC.splitlines()
|
||||
if ln.strip().startswith("turnInto(idx,type")][0]
|
||||
assert "type!=='to_do'&&type!=='toggle'" not in line
|
||||
assert "if(keep&&['image','embed','meeting','divider'" in line
|
||||
|
||||
|
||||
# ── Comportement réel : la méthode extraite de la source, exécutée sous node ─
|
||||
|
||||
def test_turn_into_converts_blocks_for_real():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node absent : test comportemental turnInto ignoré")
|
||||
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
|
||||
method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){")
|
||||
script = f"""
|
||||
function _rtSync(){{}}
|
||||
function _selClear(){{}}
|
||||
{types}];
|
||||
const E = {{
|
||||
blocks: [{{id:'b1',type:'paragraph',content:'Bonjour'}}],
|
||||
_blockTargets(i){{return [i];}},
|
||||
sync(){{}}, pushHistory(){{}}, dirty:false,
|
||||
autoSave(){{}}, render(){{}}, getEl(){{return null;}},
|
||||
{method}
|
||||
}};
|
||||
E.turnInto(0,'heading_2');
|
||||
const heading = {{type:E.blocks[0].type, content:E.blocks[0].content}};
|
||||
E.turnInto(0,'to_do');
|
||||
const to_do = {{type:E.blocks[0].type, content:E.blocks[0].content, checked:E.blocks[0].checked}};
|
||||
E.turnInto(0,'button'); // type sans texte → contenu vidé
|
||||
const button = {{type:E.blocks[0].type, content:E.blocks[0].content}};
|
||||
console.log(JSON.stringify({{heading:heading, to_do:to_do, button:button}}));
|
||||
"""
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||||
timeout=30, encoding="utf-8")
|
||||
assert out.returncode == 0, out.stderr
|
||||
res = json.loads(out.stdout)
|
||||
|
||||
# 1) le changement de format a lieu (avant la correction : aucune conversion)
|
||||
assert res["heading"] == {"type": "heading_2", "content": "Bonjour"}
|
||||
|
||||
# 2) To-do : texte conservé comme étiquette, case décochée
|
||||
assert res["to_do"] == {"type": "to_do", "content": "Bonjour", "checked": False}
|
||||
|
||||
# 3) Button (type sans texte) → contenu vidé
|
||||
assert res["button"] == {"type": "button", "content": ""}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""7.60.0 — bouton + à gauche du handle : ajoute un bloc sous le courant."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_button_rendered_left_of_the_handle():
|
||||
# le + est émis AVANT le handle dans le wrapper du bloc
|
||||
i_btn = EDITOR_JS.index("class=\"block-insert-btn\"")
|
||||
i_handle = EDITOR_JS.index("<div class=\"block-handle\">", i_btn)
|
||||
assert i_btn < i_handle
|
||||
# clic → insertion sous le bloc courant (mécanisme déjà existant)
|
||||
assert 'class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})"' in EDITOR_JS
|
||||
# réservé aux blocs de premier niveau (idx valide), comme .block-actions
|
||||
assert "const insertBtn=idx>=0?" in EDITOR_JS
|
||||
assert "addAfter(idx,type){return this.addAt(idx+1,type);}" in EDITOR_JS
|
||||
|
||||
|
||||
def test_button_style_and_visibility():
|
||||
assert ".block-insert-btn {" in APP_CSS
|
||||
assert "left: -64px;" in APP_CSS # gouttière .blocks-container (64px)
|
||||
assert ".block-wrapper:hover .block-insert-btn," in APP_CSS
|
||||
assert ".block-insert-btn:hover {" in APP_CSS
|
||||
# suit le handle quand la gouttière passe à 32px, masqué partout où il l'est
|
||||
assert ".block-insert-btn { left: -56px; }" in APP_CSS
|
||||
assert APP_CSS.count(".block-insert-btn { display: none; }") == 2
|
||||
@@ -0,0 +1,169 @@
|
||||
"""7.61.0 — menu contextuel de bloc refondu façon Notion (actions + raccourcis)."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _slice(start: str, end: str) -> str:
|
||||
i = SRC.index(start)
|
||||
j = SRC.index(end, i)
|
||||
return SRC[i:j].rstrip()
|
||||
|
||||
|
||||
# ── Panneau de gauche ───────────────────────────────────────────────────────
|
||||
|
||||
def test_left_panel_matches_spec():
|
||||
# recherche, groupe « Text », les 10 actions dans l'ordre, pied métadonnées
|
||||
assert 'placeholder="Search actions..."' in SRC
|
||||
assert "'<div class=\"bm-group\">Text</div>'" in SRC
|
||||
order = ["'↩','Turn into'", "'🎨','Color'", "'🔗','Copy link to block'",
|
||||
"'▣','Duplicate'", "'↪','Move to…'", "'✕','Delete'",
|
||||
"'💬','Comment'", "'✎','Suggest edits'", "'✨','Ask AI'",
|
||||
"'📚','Skills'"]
|
||||
chunk = SRC[src_index("d.innerHTML='<input"):src_index("+_blockMenuMeta(b);")]
|
||||
positions = [chunk.index(o) for o in order]
|
||||
assert positions == sorted(positions), positions
|
||||
# pied de page : auteur / heure / taille
|
||||
meta = _slice("function _blockMenuMeta(", "\n function blockMenuOpen(")
|
||||
for token in ("Last edited by ", "word", "character", "Today at ",
|
||||
".um-name", "updatedAt"):
|
||||
assert token in meta, token
|
||||
|
||||
|
||||
def src_index(needle: str) -> int:
|
||||
return SRC.index(needle)
|
||||
|
||||
|
||||
def test_actions_are_all_dispatched():
|
||||
dispatch = _slice("function _blockMenuClick(", "\n function _blockMenuItem(")
|
||||
for act in ("submenu", "duplicate", "copy-link", "move-to", "delete",
|
||||
"comment", "suggest", "ai", "skill", "color", "turn-type"):
|
||||
assert f"act==='{act}'" in dispatch, act
|
||||
# méthodes réelles côté éditeur
|
||||
for m in ("commentBlock(idx)", "askAI(idx)", "suggestEdit(idx)",
|
||||
"runSkill(idx,tpl)", "applyLastColor(idx)",
|
||||
"copyBlockLink(bid)", "deleteSelected(idx)", "duplicateBlock("):
|
||||
assert m in SRC, m
|
||||
# menu : pas d'entrée « copier/couper/coller les blocs » (raccourcis conservés)
|
||||
assert "act==='copy-blocks'" not in dispatch
|
||||
|
||||
|
||||
# ── Sous-menus ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_turn_into_submenu():
|
||||
turn = _slice("function _blockTurnMenuOpen(", "\n function _blockColorMenuOpen(")
|
||||
assert "_BLOCK_TURN_MENU" in turn and "check:on" in turn # ✓ du type actuel
|
||||
assert "'<span class=\"bm-check\">✓</span>'" in SRC
|
||||
for name in ("Text", "Heading 1", "Heading 4", "Bulleted list",
|
||||
"Numbered list", "To-do list", "Toggle list", "Code", "Quote",
|
||||
"Callout", "Block equation", "Synced block",
|
||||
"2 columns", "5 columns"):
|
||||
assert f"'{name}'" in SRC, name
|
||||
assert "Create '+t.cols+' columns of blocks" in SRC # info-bulle
|
||||
assert "data-cols" in SRC and "cols>=2" in SRC # 2..5 colonnes
|
||||
# Page / Page in / Toggle heading : types inexistants dans FlowDeck → absents
|
||||
assert "'Page in'" not in SRC and "'Toggle heading 1'" not in SRC
|
||||
|
||||
|
||||
def test_color_submenu():
|
||||
color = _slice("function _blockColorMenuOpen(", "\n function _blockSkillsMenuOpen(")
|
||||
for group in ("Last used", "Text color", "Background color"):
|
||||
assert f">{group}</div>" in color, group
|
||||
names = ["'Default'", "'Gray'", "'Brown'", "'Orange'", "'Yellow'",
|
||||
"'Green'", "'Blue'", "'Purple'", "'Pink'", "'Red'"]
|
||||
arr = _slice("const _BLOCK_COLOR_NAMES=", "];")
|
||||
for n in names:
|
||||
assert n in arr, n
|
||||
assert "bm-dot" in color # carré de couleur (fond)
|
||||
assert "_BM_SHORTCUTS.color" in color # raccourci « Last used »
|
||||
assert "fdLastBlockColor" in SRC # mémoire de la dernière couleur
|
||||
|
||||
|
||||
def test_skills_submenu_uses_real_skills():
|
||||
skills = _slice("function _blockSkillsMenuOpen(", "\n function _rtMac(")
|
||||
assert "fetch('/api/agent/skills'" in skills
|
||||
assert "act==='skill'" in SRC or "'skill'" in skills
|
||||
assert "prompt_template" in skills
|
||||
assert "_aiWritingRun('write',idx" in SRC # exécution réelle via l'IA
|
||||
|
||||
|
||||
# ── Survol / fermeture ──────────────────────────────────────────────────────
|
||||
|
||||
def test_hover_submenus_and_close():
|
||||
for token in ("_blockSubOpen(kind,bid,anchor)", "data-sub",
|
||||
"closest('[data-sub]')", "mouseleave", "_blockSubCloseLater",
|
||||
"function _blockMenuClose()", "_blockDocDown"):
|
||||
assert token in SRC, token
|
||||
assert "si.focus()" in SRC # recherche focalisée à l'ouverture
|
||||
|
||||
|
||||
# ── Raccourcis ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_shortcuts_implemented():
|
||||
keys = _slice("// ── v7.61 : raccourcis du menu contextuel de bloc ──",
|
||||
"document.addEventListener('keydown', function (e) {\n if (!(e.ctrlKey || e.metaKey)")
|
||||
combos = {
|
||||
"k === 'j'": "Ctrl+J (Ask AI)",
|
||||
"k === 'h'": "Ctrl+⇧+H (dernière couleur)",
|
||||
"k === 'm'": "Ctrl+⇧+M (commenter)",
|
||||
"k === 'x'": "Ctrl+⇧+Alt+X (suggest edits)",
|
||||
"k === 'l'": "Alt+⇧+L (copier le lien)",
|
||||
"k === 'p'": "Ctrl+⇧+P (déplacer vers…)",
|
||||
"k === 'd'": "Ctrl+D (dupliquer)",
|
||||
"k === 'delete'": "Del (supprimer, menu ouvert uniquement)",
|
||||
"k === 'escape'": "Échap (fermer)",
|
||||
}
|
||||
for token, label in combos.items():
|
||||
assert token in keys, label
|
||||
# Del n'agit jamais pendant la saisie
|
||||
assert "tgt.isContentEditable" in keys
|
||||
# le libellé affiché dans le menu correspond aux combinaisons réelles
|
||||
shortcuts = _slice("const _BM_SHORTCUTS=", ";")
|
||||
for hint in ("Alt+⇧+L", "Ctrl+D", "Ctrl+⇧+P", "Del", "Ctrl+⇧+M",
|
||||
"Ctrl+⇧+Alt+X", "Ctrl+J", "Ctrl+⇧+H"):
|
||||
assert hint in shortcuts, hint
|
||||
|
||||
|
||||
# ── Comportement réel : conversion en colonnes (node) ───────────────────────
|
||||
|
||||
def test_turn_into_columns_creates_children_for_real():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node absent : test comportemental ignoré")
|
||||
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
|
||||
method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){")
|
||||
script = f"""
|
||||
function _rtSync(){{}}
|
||||
function _selClear(){{}}
|
||||
var _n=0; function genId(){{return 'g'+(++_n);}}
|
||||
{types}];
|
||||
const E = {{
|
||||
blocks:[{{id:'b1',type:'paragraph',content:'Titre'}}],
|
||||
_blockTargets(i){{return [i];}}, sync(){{}}, pushHistory(){{}}, dirty:false,
|
||||
autoSave(){{}}, render(){{}}, getEl(){{return null;}}, _pickSyncedBlock(){{}},
|
||||
{method}
|
||||
}};
|
||||
E.turnInto(0,'columns',3);
|
||||
console.log(JSON.stringify({{type:E.blocks[0].type, kids:E.blocks[0].children.length}}));
|
||||
"""
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||||
timeout=30, encoding="utf-8")
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == {"type": "columns", "kids": 3}
|
||||
|
||||
|
||||
# ── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_menu_styles():
|
||||
for sel in (".block-menu .bm-group {", ".block-menu .bm-foot {",
|
||||
".block-menu .bm-check {", ".block-menu .bm-dot {",
|
||||
".block-menu-sub { min-width: 230px; max-height: 60vh;"):
|
||||
assert sel in CSS, sel
|
||||
Reference in New Issue
Block a user