Compare commits

...
9 Commits
Author SHA1 Message Date
bruno 18c72d77fe feat: menu + — Connecteurs (socle) — phase 5/8 (v7.55.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 15m16s
FlowDeck CI / docker (push) Skipped
- Table agent_connectors (migration 32) : colonnes plates name, url,
  secret_encrypted, enabled, status, detail — config_json remis (YAGNI, les
  scopes OAuth des phases 6-7 ajouteront le leur).
- app/services/connectors.py : 1 fichier au lieu du package connectors/ —
  3 natifs (gitea, github, web) servis à la volée avec statut sans réseau,
  CRUD des personnels (URL validée par _validate_url = garde SSRF, clé chiffrée
  Fernet et jamais renvoyée — seul has_secret), probe() qui persiste
  status/detail, connector_fetch() borné à 20 000 car.
- API /api/agent/connectors : GET, POST (400 URL privée), PATCH, DELETE,
  POST /connectors/probe — 401 sans session, CSRF global. OpenAPI 519 chemins.
- Outil LLM connector_fetch (26e outil) : un seul outil qui dispatche vers
  Gitea/GitHub/web/personnalisé (id ou nom ou kind + path + query) au lieu d'un
  outil par connecteur ; désactivé/inconnu = erreur outil, jamais de run cassé.
- Menu + : section « Connecteurs » (catalogue avec badge ✓/✗/⚠/?), fiche par
  connecteur (Tester, Activer/Désactiver, Supprimer — masqués pour les natifs),
  formulaire « Ajouter un connecteur personnalisé » (clé en type=password).
- Tests : tests/test_v755_connectors.py (13) — 3 natifs, roundtrip Fernet de la
  clé, 5 URLs refusées (localhost, 127.0.0.1, metadata cloud, ftp:, file:),
  toggle persisté, 401, probe OK/erreur, outil complet, câblage menu ;
  test_v751/v753/v754 adaptés (1 seule section « bientôt » = plugins).
  Suite complète 1306 verts (-n auto), ruff 0, eslint 0 problème.
2026-10-06 23:26:19 -04:00
bruno 64c85caffc feat: menu + — Mémoire de l'agent — phase 4/8 (v7.54.0)
FlowDeck CI / lint (push) Successful in 2m13s
FlowDeck CI / test (push) Failing after 3h10m19s
FlowDeck CI / docker (push) Skipped
- Le moteur n'envoie jamais l'historique (system + objectif courant seulement)
  : chaque run repartait de zéro. Nouveau service app/services/agent_memory.py —
  une ligne résumé par conversation (table agent_memory, migration 31),
  écrite en fin de run et ré-injectée au contexte du run suivant.
- context_for() : bloc « ## Mémoire de la conversation », budget 4000 car.
  (troncature gauche + marqueur). remember() : 1 note/échange (180 → 700 car.),
  20 notes max, upsert, erreurs journalisées — un run ne peut pas échouer à
  cause de la mémoire. Toggle OFF = ni lecture ni écriture.
- Toggle « Mémoire » dans le menu + : entrée racine à l'état réel (🧠/💭 +
  libellé), PATCH /api/agent/conversations/{id} avec memory_enabled ajouté au
  whitelist, état lu à l'ouverture et à la création, défaut AGENT_MEMORY_DEFAULT
  (settings.agent_memory_default) + memory_enabled dans la réponse de création.
- Décision : mémoire d'espace (lignes workspace_id NULL) remise — YAGNI, rien
  ne l'écrivait ; noté ponytail: dans le service avec le chemin de montée.
- Tests : tests/test_v754_memory.py (8) dont 2 runs réels du moteur (FakeLLM
  capture le prompt : mémoire présente ON / absente OFF) ; test_v751 et
  test_v753 adaptés (2 sections « bientôt » restantes). Suite complète 1293
  verts (-n auto, 2e run ; 1 échec intermittent sur test_v56_import URL au
  1er run, vert isolé + vert au run suivant = flake préexistant), ruff 0,
  eslint 0 problème. OpenAPI 516 chemins / 7.54.0.
2026-10-06 22:53:15 -04:00
bruno a68e8848ea feat: menu + — Design System – Canevas — phase 3/8 (v7.53.0)
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / test (push) Failing after 14m56s
FlowDeck CI / docker (push) Skipped
- Section « Design System – Canevas » : liste des canevas (intégrés +
  personnels, GET /board/api/page-templates) puis page de détail avec
  « Créer une page à partir du canevas », « Insérer dans le document ouvert »
  (désactivé sans document ouvert) et « Enregistrer le document ouvert comme
  canevas ».
- GET /board/api/page-templates/{id}/blocks (nouvelle route) : blocs d'un
  canevas — intégré via template_id=0&key=, sinon id ; 404 clé/id inconnu.
  Insertion côté front via ensureBlockIds() global + concat dans E.blocks
  + autoSave/render.
- Canevas « Design System » ajouté aux builtins (callout tokens, TOC, toggles
  composants, grille, checklist revue UI, citation).
- Fix : le front du panneau appelait /api/page-templates (404 silencieux) —
  le router est sous prefix=/board → /board/api/page-templates.
- Piège : pour /use la clé builtin se passe DANS LE BODY (body.key), pas en
  query string — test garde-fou ajouté.
- Tests : tests/test_v753_canvases.py (5) + test_v751 adapté. Suite complète
  1285 verts (-n auto), ruff 0, eslint 0 problème. OpenAPI 516 chemins.
2026-10-06 21:59:34 -04:00
bruno 18a2d45d46 feat: menu + — Compétences-skills « Gérer » + « Parcourir » — phase 2/8 (v7.52.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 13m54s
FlowDeck CI / docker (push) Skipped
- Sous-menu « Compétences-skills » : 11 skills builtin + skills enregistrés,
  libellés affichés « Deep research » (/research) et « Skill-creator »
  (/create-new-skill) via FD_SKILL_ALIAS (slugs intacts) ; le clic épingle le
  skill au contexte, même chemin que la palette /.
- « Gérer les compétences » : section du menu + formulaire intégré (nom,
  description, prompt) — création, édition, suppression (chip épinglé nettoyé),
  export JSON téléchargé, import fichier avec overwrite (pas de doublon).
- « Parcourir les compétences » : section galerie filtrable (nom/description/
  slug), installation en 1 clic (installGallerySkill réutilisé).
- PATCH /api/agent/skills/{skill_id} créé : édition partielle, whitelist de
  colonnes, 400/401/404/409. OpenAPI régénéré : 515 chemins, version 7.52.0.
- En-tête ← + titre de section dans le menu (navigation à 3 niveaux).
- Fix : épingler un skill enregistré n'efface plus le message déjà tapé
  (_pinDbSkill ne vide le composer que s'il est vide).
- Tests : tests/test_v752_skills_menu.py (7) + test_v751 adapté. Suite
  complète 1280 verts (-n auto), ruff 0, eslint 0 problème.
2026-10-06 21:39:24 -04:00
bruno f271ac9b7a feat: menu + de l'assistant en hub de contexte — phase 1/8 (v7.51.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 3h10m48s
FlowDeck CI / docker (push) Skipped
- Bouton + : menu à sections (fichiers/répertoires, compétences-skills,
  connecteurs, Design System – Canevas, Add plugins, Mémoire on/off).
  Sections pas encore livrées affichées « bientôt (phase N) » mais désactivées,
  navigation clavier ↑/↓/Entrée/Échap, focus visible, la frappe referme le menu.
- Parcours « Parcourir… » : un niveau par appel via GET /api/nav/menu
  (contrat : dossier = icon 'folder'), fil d'Ariane cliquable, épingle de dossier
  via la ligne « 📌 Épingler le dossier ».
- Jeton folder:<id> résolu par ContextBuilder._single_folder() : titre du dossier
  + documents directs, budget ~12k caractères (marqueur « tronqué »), enfants
  directs seulement.
- « Rechercher… » conserve l'ancien sélecteur de mentions (@ inline + recherche
  par nom) : régression zéro sur le chemin existant.
- Tests : tests/test_v751_plus_menu.py (7 tests). Suite complète 1273 verts
  (-n auto), ruff check app tests propre, eslint static/js 0 erreur.
- Docs : CHANGELOG, ROADMAP (phase 1 cochée), docs/V74_Agent_Plus_Menu.md statut.
2026-10-06 20:01:47 -04:00
bruno f006880394 docs: plan par phases du menu + de l'assistant + design V74
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / test (push) Failing after 13m39s
FlowDeck CI / docker (push) Skipped
- ROADMAP : section « v7.51.0 → v7.58.0 — Menu + de l'assistant »
  (8 phases : menu hub/fichiers-répertoires, compétences, canevas,
  mémoire, connecteurs socle, Google+M365, Discord/Telegram/MCP, plugins),
  état des lieux lu dans le code, aucun case cochée.
- docs/V74_Agent_Plus_Menu.md : maquette, découpage front/back, modèle de
  données, sécurité (SSRF, secrets Fernet, CSRF), décisions D1-D7,
  pièges repo, critères d'acceptation par phase.
2026-10-06 19:35:22 -04:00
bruno 81746d9440 fix: peek de Library nu comme local-workspace - iframe pleine (v7.50.2)
FlowDeck CI / test (push) Failing after 13m34s
FlowDeck CI / lint (push) Successful in 2m32s
FlowDeck CI / docker (push) Skipped
.peek-body gardait padding:16px 20px + overflow-y:auto (heritage de l'ancien
peek a rendu HTML) : l'iframe etait amochee (519x816 mesures contre 559x848
en local-workspace) avec bande grise autour du document + double scrollbar.
Corps nu (padding:0, overflow:hidden) : l'editeur remplit le panneau,
exactement comme le peek de /local-workspace.

Mesure Playwright (cookie de session forge, les 14 boutons Open de Library) :
embed OK partout, bases hydratees, aucun jeton « Loading database... ».
Test: test_library_peek_body_has_no_inset_padding. Suite 1266/0 (-n auto)
+ ruff 0. Bump VERSION/main.py 7.50.2, CHANGELOG, WORKLOAD, OpenAPI.
2026-10-06 15:53:04 -04:00
bruno f53205bc45 fix: peek des bases hydrate FlowDeckDB + Open de ligne, tableau pleine largeur (v7.50.1)
FlowDeck CI / test (push) Failing after 14m37s
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / docker (push) Skipped
- page_editor_embed.html incluait seulement _page_editor_scripts.html :
  window.FlowDeckDB etait undefined dans l'iframe -> une base ouverte en side
  peek restait a « Loading database... ». Include _database_table_scripts.html
  ajoute (meme traitement que page_editor.html).
- Le panneau #db-side-peek n'existait que dans _database_table.html : hors page
  dediee, openPageInSidePeek etait un no-op. Desormais cree a la demande par
  ensureDbPeek (database_table.js) ; markup unique retire du partial.
- .database-table-container : plafond 1100px retire (regle heritee de Library)
  + max-width 100% pour .db-first et database-page-title : le tableau prend la
  largeur du document, plus de barre de scroll parasite sous le tableau.
- Tests: test_embed_editor_can_render_inline_databases. Suite 1265/0 (-n auto)
  + ruff 0. Bump VERSION/main.py 7.50.1, CHANGELOG, WORKLOAD, OpenAPI.
2026-10-06 15:15:01 -04:00
bruno 3706689eca fix: side peek = document seul en edition + bouton Open calendrier/Kanban (v7.50.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 13m47s
FlowDeck CI / docker (push) Skipped
- Les 4 peeks (library.js, local_workspace.js, my_tasks.js, database_table.js)
  chargent /pages/{id}?embed=1 : le panneau ne montre plus que le document en
  mode edition, sans sidebar ni barre (regression v7.49.0). Le cadre du
  panneau (close/full/resize partages fdWirePeekResize) est inchange.
- Calendrier My Tasks : le handler testait e.target.dataset.openRow mais les
  boutons .mt-cal-open ne contiennent que leur <svg> -> clic sur l'icone =
  e.target = <svg>, rien ne partait. cause racine corrigee par
  t.closest('[data-open-row]') : un seul chemin tableau/calendrier/Kanban.
- Kanban My Tasks : meme bouton Open ajoute sur les tuiles (openButton(t),
  overlay absolu revele au survol de la carte .mt-card).
- Menu contextuel global « Open in side peek » : etait un toast d'erreur ;
  route maintenant vers le peek de la page courante (_libData.openPeekById /
  _wsData.openSidePeek), repli nouvel onglet ailleurs.
- /pages/{id}?embed=1 sur une page de base (content_format=collection)
  rendait un editeur de blocs vide : le template collection est garde en
  embed (body.embed-mode retire sidebar+barre, le tableau reste). Bouton
  pleine page des peeks de bases : navigue via dataset.page (URL propre)
  au lieu du src ?embed=1.
- Tests : garde de coherence inversee (?embed=1 exige sur les 4 fichiers),
  +3 nouveaux (closest, tuiles Kanban, collection embed). Fix d'un test
  pre-existant casse (assertion panel.style.display, reecriture vanilla JS
  v7.49.0). Suite 1264 passed / 0 failed (-n auto) + ruff 0.
- Bump VERSION/main.py 7.50.0, CHANGELOG, WORKLOAD, OpenAPI regenere.
2026-10-06 12:48:48 -04:00
36 changed files with 3149 additions and 146 deletions
+269
View File
@@ -1,5 +1,274 @@
# Changelog - FlowDeck
## 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
- **Sous-menu « Compétences-skills »** — 11 skills builtin + les skills
enregistrés, avec **libellés affichés** « Deep research » (`/research`) et
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS` (slugs intacts) ;
le clic épingle le skill au contexte, même chemin que la palette `/`.
- **« Gérer les compétences »** — section du menu : liste des skills,
formulaire intégré (nom, description, prompt) pour créer/éditer, suppression
avec nettoyage du chip épinglé, **export JSON** téléchargé et **import** en
fichier (`overwrite:true` → jamais de doublon).
- **« Parcourir les compétences »** — section galerie filtrable (nom /
description / slug), installation en 1 clic (`installGallerySkill`).
- **`PATCH /api/agent/skills/{skill_id}`** — édition partielle, whitelist de
colonnes figée côté serveur, réponses 400 (champ vide / aucun champ),
401 (sans session), 404, 409 (nom déjà pris). **OpenAPI régénéré** :
`docs/openapi-v2.json` → 515 chemins, `info.version` 7.52.0.
- **En-tête ← + titre de section** dans le menu (indispensable à 3 niveaux :
racine → compétences → gérer/parcourir/formulaire).
- **Fix** : épingler un skill enregistré **n'efface plus** le message déjà tapé
(`_pinDbSkill` ne vide le composer que s'il est vide).
### Tests
- `tests/test_v752_skills_menu.py` — **7 tests** : PATCH complet + partiel,
validations 400/404, 401 sans session (CSRF valide pour isoler la route),
import sans doublon (409 puis `overwrite` → 1 seule ligne), aller-retour
export → import, câblage des sections + alias, markup rendu dans la page.
- `tests/test_v751_plus_menu.py` adapté : 4 sections « bientôt » au lieu de 5.
- **Suite complète : 1280 passed / 0 failed** (`-n auto`), `ruff check app tests`
propre, `eslint static/js/agent_panel_2.js` **0 problème**.
## v7.51.0 (2026-10-06) — Menu + de l'assistant : hub de contexte (phase 1/8)
### Added
- **Menu à sections sur le bouton +** (`agent_panel.html` + `agent_panel_2.js`) :
le + ouvre *Ajouter des fichiers ou répertoires*, *Compétences-skills*,
*Connecteurs*, *Design System – Canevas*, *Add plugins*, *Mémoire (on/off)*.
Sections pas encore livrées affichées « bientôt (phase N) » et **désactivées**
(décision D3 : le menu ne promet rien que le code ne fait). Navigation clavier
↑/↓/Entrée/Échap, focus visible, Échap revient d'un niveau.
- **Parcours fichiers & répertoires** — « Parcourir… » affiche un niveau à la fois
via `/api/nav/menu` (contrat : dossier = `icon === 'folder'`) avec fil d'Ariane
cliquable ; un document s'épingle (`document:<id>`), un dossier s'épingle depuis
la ligne « 📌 Épingler le dossier » (`folder:<id>`).
- **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` : titre du
dossier + documents directs, budget ~12k caractères (marqueur « contenu du
dossier tronqué »). Enfants directs seulement — récursion à ajouter si les
arbres profonds deviennent réels.
- **« Rechercher… »** conserve l'ancien sélecteur de mentions (recherche par nom,
`@` inline) à l'identique : zéro régression du chemin existant.
### Tests
- `tests/test_v751_plus_menu.py` — **7 tests** : résolution du dossier, budget
borné, id inconnu, contrat `/api/nav/menu`, rendu du menu dans la page,
6 sections dont 5 désactivées, jeton `folder:` côté front et côté backend.
- **Suite complète : 1273 passed / 0 failed** (`-n auto`), `ruff check app tests`
propre, `eslint static/js` **0 erreur**.
## v7.50.2 (2026-10-06) — Peek de Library : nu comme local-workspace (plus de bande ni double scrollbar)
### Fixed
- **Le document ouvert par le bouton « Open » de Library n'occupait pas le
panneau** — `.peek-body` gardait un `padding:16px 20px` + `overflow-y:auto`
(héritage de l'ancien peek à rendu HTML) : l'iframe était amochée
(519×816 mesurés contre 559×848 en local-workspace) avec une bande grise
autour du document et une double scrollbar. Corps nu (`padding:0`,
`overflow:hidden`) : l'éditeur remplit le panneau, exactement comme le peek
« parfait » de /local-workspace (mesuré Playwright, cookie de session forgé).
### Tests
- `test_library_peek_body_has_no_inset_padding`. Suite 1266 passed / 0 failed
(-n auto) + ruff 0.
## v7.50.1 (2026-10-06) — Peek des bases : le contenu se rend enfin + tableau pleine largeur
### Fixed
- **Une base ouverte en side peek restait à « Loading database… »** —
`page_editor_embed.html` (le peek de `/pages/{id}?embed=1`) n'incluait pas
`database_table.js` : `window.FlowDeckDB` était undefined dans l'iframe et
le bloc `embed/collection` ne s'hydrat jamais. Inclus désormais (même
traitement que `page_editor.html`).
- **L'icône « Open » de fin de ligne d'un tableau de base ne faisait rien
hors page dédiée** — le panneau `#db-side-peek` n'existait que dans le
markup de `_database_table.html` ; sans lui, `openPageInSidePeek` était un
no-op. Le panneau est créé à la demande par `database_table.js`
(`ensureDbPeek`, comme `mt-peek` de My Tasks) : markup unique, le partial
ne contient plus le panneau statique.
- **Barre de scroll sous le tableau des bases** — `.database-table-container`
plafonnait à 1100 px (règle héritée de Library) : le tableau ne prenait
pas la largeur du document. Plafond retiré ; `max-width: 100%` aussi pour
les conteneurs `.db-first` (base embarquée en premier bloc) et le titre
des pages de base — les colonnes dictent seules le scroll horizontal.
### Tests
- `test_embed_editor_can_render_inline_databases` (include peek + panneau
créé à la demande, source unique de markup). Suite 1265 passed / 0 failed
(-n auto) + ruff 0.
## v7.50.0 (2026-10-06) — Side peek : le document seul (mode édition), sans sidebar ni barre
### Fixed
- **Tous les boutons « Open » ouvraient l'habillage pleine page dans le panneau
latéral** — `library.js` (OPEN de hover), `local_workspace.js` (↢ Open +
« Open in side peek » du menu contextuel) et `my_tasks.js` (peek des tâches)
chargeaient `/pages/{id}` SANS `?embed=1` : le peek affichait sidebar +
barre de navigation + éditeur (régression v7.49.0). Tous repassent en
`/pages/{id}?embed=1` = le document en mode édition SEUL ; le cadre du
panneau (garde/close/resize partagés `fdWirePeekResize`) reste identique.
- **Le bouton « Open » du calendrier My Tasks ne faisait rien** — cause
racine : le handler de clic testait `e.target.dataset.openRow`, mais les
boutons `.mt-cal-open` du calendrier (et du Kanban) ne contiennent que leur
`<svg>` : cliquer l'icône donnait `e.target` = `<svg>` sans dataset. Le
handler résout désormais `t.closest('[data-open-row]')` — un seul chemin
pour tableau, calendrier (5 vues) et Kanban.
- **Menu contextuel global « Open in side peek »** — le clic ne faisait
qu'afficher « Side peek not available in browser » : il route maintenant
vers le peek de la page courante (`_libData.openPeekById` en Library,
`_wsData.openSidePeek` en workspace local), repli nouvel onglet ailleurs.
- **Peek d'une page de base (`content_format='collection'`)** — `?embed=1`
forçait le template éditeur : une base ouverte dans le peek montrait un
éditeur de blocs vide au lieu du tableau. Le template collection est gardé
en mode embed (body.embed-mode retire sidebar + barre, le tableau reste).
### Added
- **Bouton « Open » sur les tuiles du Kanban My Tasks** — le même que les
lignes du tableau (`openButton(t)`, `.mt-hover-only` révélé au survol de la
carte, overlay absolu pour ne pas déformer la tuile) ; ouvre le contenu de
la tâche dans le panneau latéral.
- **Bouton « pleine page » des peeks de bases** — navigue vers
`/pages/{pageId}` (pleine page, sidebar + barre conservées) au lieu de
l'URL `?embed=1` de l'iframe ; `dataset.page` porte l'id résolu.
### Tests
- `test_my_tasks_workspace_coherence.py` : les 4 peek JS (`library`,
`my_tasks`, `database_table`, `local_workspace`) doivent porter
`?embed=1` (gate de cohérence inversé — l'ancien test imposait l'URL nue) ;
nouveaux : résolution `closest('[data-open-row]')`, bouton Open sur tuiles
Kanban, collection embed = tableau. Suite 1264 passed / 0 failed
(-n auto) + ruff 0.
## v7.49.2 (2026-10-05) — Pages de base : titre au-dessus, tableau à gauche
### Fixed
+194
View File
@@ -284,6 +284,200 @@ 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-5 ✅ 2026-10-06 · phases 6-8 planifiées)
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
> un menu à sections, tel que demandé :
>
> ```
> + ├─ Ajouter des fichiers ou répertoires … → picker actuel + parcours de l'arborescence
> ├─ Compétences-skills … … … … … … … … → Deep research, Skill-creator, … + Gérer + Parcourir
> ├─ Connecteurs … … … … … … … … … … … → Parcourir + Ajouter un connecteur personnalisé
> ├─ Design System – Canevas … … … … … → galerie de canevas (templates de page)
> ├─ Add plugins … … … … … … … … … … … → catalogue de modules on/off
> └─ Mémoire (on/off) … … … … … … … … → mémoire persistante de l'agent
> ```
>
> **Doc de design** : [`docs/V74_Agent_Plus_Menu.md`](docs/V74_Agent_Plus_Menu.md)
> — maquette, découpage front/back, modèle de données, endpoints, décisions, pièges.
**État des lieux lu dans le code le 2026-10-06** (constat **avant** la phase 1) :
- Le **+** = `toggleAddPicker()` (`static/js/agent_panel_2.js:1074`) → menu **plat** de
mentions (`GET /api/agent/mentions`, `app/routers/agent.py:596`) : documents,
collections, pages. **Pas de dossiers, pas de sous-menu, pas de section.**
- **Compétences** : 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) +
table `agent_skills` (CRUD `/api/agent/skills`) + galerie de 17 presets — mais
**aucune UI « Gérer » ni « Parcourir »** : tout passe par la palette `/`.
- **Canevas** : `GET /api/page-templates` (built-ins `app/services/block_templates.py` +
personnels) et `POST /api/page-templates/{id}/use` existent déjà, **jamais appelés
depuis le panneau agent**.
- **Connecteurs / Plugins / Mémoire** : **inexistants** — 0 hit `connector|plugin|memory`
dans `app/` hors « in-memory ».
**Ordre** : phases 1 → 8, chacune indépendante et livrable (version + tests verts +
CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
### Phase 1 — v7.51.0 — Menu hub + fichiers/répertoires · effort S–M ✅ (livrée 2026-10-06)
- [x] **Structure du menu** — sections + sous-menus, navigation clavier (↑/↓/Entrée/Échap),
styles réutilisés depuis `.fd-ap-mention-menu` ; les sections pas encore livrées
s'affichent « bientôt » et désactivées (le menu ne promet rien que le code ne fait)
→ `FD_PLUS_MENU` / `FD_PLUS_FILES` (`static/js/agent_panel_2.js`), markup
`.fd-ap-plus-menu` (`agent_panel.html`), Échap remonte d'un niveau, la frappe
referme le menu
- [x] **Fichiers & répertoires** — picker actuel (recherche) **+ mode « Parcourir… »** :
arborescence **un niveau par appel via `GET /api/nav/menu?parent_id=`**
(contrat : dossier = `icon === 'folder'` — plus simple que le tree récursif,
déjà testé), fil d'Ariane cliquable ; un répertoire s'épingle comme un fichier
(ligne « 📌 Épingler le dossier »)
- [x] **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` →
`_single_folder()` : titre du dossier + documents directs, budget ~12k caractères
(marqueur « tronqué ») ; enfants directs seulement (récursion si besoin réel)
- [x] **Tests** — `tests/test_v751_plus_menu.py` : **7 tests** (résolution, budget,
id inconnu, contrat `/api/nav/menu`, rendu du menu, 6 sections / 5 désactivées
à la livraison de la phase 1, jeton `folder:` front+back) ; **suite complète
1273 verts** à la livraison, `ruff` 0, `eslint` 0 erreur
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.51.0**, CHANGELOG, ce ROADMAP
### Phase 2 — v7.52.0 — Compétences : « Gérer » + « Parcourir » · effort M ✅ (livrée 2026-10-06)
- [x] **Sous-menu Compétences** — 11 builtin + skills enregistrés listés, le clic
épingle le skill au contexte (même chemin que la palette `/`, comportement
conservé)
- [x] **Libellés affichés** — alias « Deep research » (`/research`) et
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS`, slugs intacts
- [x] **« Gérer les compétences »** — **section du menu + formulaire intégré**
(nom, description, prompt) plutôt que modale : CRUD `POST`/`PATCH`/`DELETE`
sur `/api/agent/skills` — **`PATCH /api/agent/skills/{id}` créé** (champs
présents seuls, whitelist de colonnes, 400/401/404/409) + **export JSON**
(`/skills/{id}/export` → téléchargement) et **import fichier**
(`/skills/import`, `overwrite:true` → pas de doublon) ; suppression = nettoyage
du chip épinglé
- [x] **« Parcourir les compétences »** — section galerie sur
`GET /api/agent/skills/gallery`, **filtre** (nom/description/slug),
installation en 1 clic (réutilise `installGallerySkill`)
- [x] **En-tête ← + titre** dans le menu (navigation à 3 niveaux)
- [x] **Fix** — épingler un skill enregistré n'efface plus le message tapé
- [x] **Tests** — `tests/test_v752_skills_menu.py` : **7 tests** (PATCH complet +
partiel, 400/404, 401 sans session, import sans doublon, export→import,
câblage sections/alias, markup) ; `test_v751` adapté (4 sections « bientôt ») ;
**suite complète 1280 verts**, `ruff` 0, `eslint` 0 problème
- [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 ✅ (livrée 2026-10-06)
- [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 ✅ (livrée 2026-10-06)
- [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 ✅ (livrée 2026-10-06)
- [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
- [ ] **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é)
### 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
### 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)
---
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 5 livrées le
2026-10-06 (v7.51.0 → v7.55.0)**, phases 6-8 à valider une par une avant « go ».
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
---
## 🔜 Prochaines versions
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
+1 -1
View File
@@ -1 +1 @@
7.49.2
7.55.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.49.2 (pages de base : titre au-dessus du tableau, tableau calé à gauche au ras du sidebar) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+5
View File
@@ -126,6 +126,11 @@ class Settings(BaseSettings):
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
# ── 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é).
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.49.2",
version="7.55.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+43
View File
@@ -1584,3 +1584,46 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_collections_task "
"ON collections(is_task, workspace_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
)"""
)
+110 -5
View File
@@ -15,7 +15,7 @@ from fastapi.responses import 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, 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 +199,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 +251,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()
@@ -498,6 +504,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.patch("/skills/{skill_id}")
def update_skill(request: Request, skill_id: int, body: dict = Body(default={})):
"""Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).
Seuls les champs présents dans ``body`` sont mis à jour ; la liste des
colonnes est figée ici (jamais interpolée depuis la requête).
"""
_current_user_id(request)
fields: dict = {}
if "name" in body:
name = str(body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name est requis")
fields["name"] = name
if "description" in body:
fields["description"] = str(body.get("description") or "")
if "prompt_template" in body:
fields["prompt_template"] = str(body.get("prompt_template") or "")
if "allowed_tools" in body:
fields["allowed_tools_json"] = json.dumps(body.get("allowed_tools") or [])
if not fields:
raise HTTPException(status_code=400, detail="Aucun champ à mettre à jour")
cols = ", ".join(f"{k}=?" for k in fields)
with get_conn() as conn:
if not conn.execute("SELECT id FROM agent_skills WHERE id=?", (skill_id,)).fetchone():
raise HTTPException(status_code=404, detail="Skill introuvable")
try:
conn.execute(
f"UPDATE agent_skills SET {cols} WHERE id=?",
(*fields.values(), skill_id),
)
conn.commit()
except Exception as exc: # noqa: BLE001 — contrainte UNIQUE(name)
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
return {"id": skill_id, "status": "updated", "fields": sorted(fields)}
@router.post("/skills/{skill_id}/apply")
def apply_skill(request: Request, skill_id: int):
"""Create a conversation pre-loaded with a skill, ready to run."""
@@ -590,6 +633,68 @@ 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 : 3 natifs (statut dérivé de la config) + personnels."""
_current_user_id(request)
return {"connectors": connectors.list_connectors()}
@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 "")
)
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."""
_current_user_id(request)
target = str(body.get("connector") or "").strip()
if not target:
raise HTTPException(status_code=400, detail="connector est requis")
try:
return await connectors.probe(target)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
+34
View File
@@ -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 ──
+4 -2
View File
@@ -217,8 +217,10 @@ def view_page_root(request: Request, page_id: int):
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
).render(),
)
# Select template: collection pages use database table view
if page.get("content_format") == "collection" and not embed:
# Select template: collection pages use database table view (aussi en
# mode embed : `page_editor_collection.html` + body.embed-mode = le
# tableau SANS sidebar ni barre — le peek d'une base reste le tableau).
if page.get("content_format") == "collection":
template = env.get_template("page_editor_collection.html")
else:
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
+8
View File
@@ -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,
+89
View File
@@ -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)
+28
View File
@@ -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."),
],
},
}
+255
View File
@@ -0,0 +1,255 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
3 connecteurs **natifs** (gitea, github, web) servis à la volée depuis la config,
plus des connecteurs **personnalisation** persistés (URL + clé) en base.
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 logging
from app.config import settings
from app.db import get_conn
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web")
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
def native_state(kind: str) -> tuple[str, str]:
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
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 list_connectors() -> list[dict]:
"""Catalogue : 3 natifs (toujours présents) + les connecteurs personnels."""
out: list[dict] = []
for kind in NATIVE_KINDS:
status, detail = native_state(kind)
out.append({
"id": None, "builtin": True, "kind": kind, "name": kind.capitalize(),
"url": "", "enabled": True, "status": status, "detail": detail,
"has_secret": False,
})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
"FROM agent_connectors ORDER BY id"
).fetchall()
for r in rows:
out.append({
"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"]),
})
return out
def get(connector_id: int) -> dict | None:
with get_conn() as conn:
r = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
"FROM agent_connectors WHERE id=?", (connector_id,)
).fetchone()
if not r:
return None
return {"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"])}
# ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "") -> 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")
url = _validate_url((url or "").strip()) # 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) "
"VALUES (?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", ""),
)
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."""
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret = ""
if connector_id:
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 secret:
headers["Authorization"] = f"Bearer {secret}"
headers["X-Api-Key"] = secret
return headers
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 ("custom", 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 ("custom", row["id"])
raise ValueError(f"Connecteur inconnu: {token}")
async def probe(connector: str) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
kind, ref = _resolve(connector)
try:
if 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))
code, _ = await _get(row["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 == "custom":
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 = "") -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector)
path = (path or "").strip()
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 = row["url"].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]}
+34 -1
View File
@@ -118,7 +118,7 @@ class ContextBuilder:
return "\n".join(lines)
def _mentions_context(self, mentions: list[str]) -> str:
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
"""Resolve @document:x / @collection:x / @page:y / @folder:d / @repo:o/r mentions.
Mentions bring the *actual content* of the referenced object into the
context so the LLM can summarise / rewrite / analyse it directly without
@@ -135,6 +135,8 @@ class ContextBuilder:
elif m.startswith("page:"):
pid = m.split(":", 1)[1]
lines.append(self._single_page(pid))
elif m.startswith("folder:"):
lines.append(self._single_folder(m.split(":", 1)[1]))
elif m.startswith("repo:"):
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
elif m == "ws":
@@ -209,6 +211,37 @@ class ContextBuilder:
return f"{head}:\n{body[:9000]}"
return head
def _single_folder(self, fid: str) -> str:
"""Folder (répertoire) mention : titre du dossier + ses documents directs.
ponytail : enfants directs seulement, budget ~12k caractères —
ajouter une profondeur récursive si les arbres profonds deviennent réels.
"""
with get_conn() as conn:
row = conn.execute(
"SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL",
(fid,),
).fetchone()
if not row:
return f"- dossier #{fid}: introuvable"
kids = conn.execute(
"SELECT id FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(fid,),
).fetchall()
title = row["title"] or "Sans titre"
head = f"- dossier #{row['id']} **{title}** ({len(kids)} élément(s))"
out = [head]
used = len(head)
for k in kids:
part = self._single_document(str(k["id"]))
if used + len(part) + 1 > 12000:
out.append("- … (contenu du dossier tronqué)")
break
used += len(part) + 1
out.append(part)
return "\n".join(out)
def _single_collection(self, cid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
+43 -1
View File
@@ -1072,6 +1072,48 @@ 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 ""),
)
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})
TOOL_CLASSES = [
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
@@ -1080,7 +1122,7 @@ TOOL_CLASSES = [
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
WebSearch, FetchUrl, SearchCode,
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
]
+3 -17
View File
@@ -125,21 +125,7 @@
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
</div>
<!-- Side Peek Panel for Database Pages (vanilla JS, piloté par database_table.js) -->
<div id="db-side-peek" class="db-side-peek">
<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>
<div class="db-side-peek-header">
<button id="db-side-peek-close" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="db-side-peek-title" id="db-side-peek-title"></div>
<button id="db-side-peek-full" title="Open full page">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</button>
</div>
<div class="db-side-peek-body">
<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>
<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
</div>
</div>
<!-- Le panneau de peek (#db-side-peek) est créé à la demande par
database_table.js (ensureDbPeek) : une seule source de markup pour
la page de base et les bases embarquées dans un document. -->
</div>
+79 -1
View File
@@ -180,6 +180,26 @@ body.fd-ap-resizing *{cursor:col-resize!important}
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
/* Menu + (sections) — réutilise le skin du menu de mentions */
.fd-ap-plus-menu{max-height:min(340px,60vh)}
.fd-ap-plus-menu .fd-mention-item.disabled{opacity:.45;cursor:default;background:none}
.fd-ap-plus-menu .fd-mention-item.disabled:hover{background:none}
.fd-plus-head{display:flex;align-items:center;gap:8px;padding:7px 11px 6px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
.fd-plus-back{background:none;border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);border-radius:7px;width:26px;height:22px;cursor:pointer;line-height:1;flex-shrink:0}
.fd-plus-back:hover{background:var(--bg-hover,#2b2b2b)}
.fd-plus-title{font-size:11px;font-weight:600;color:var(--text-secondary,#ddd);text-transform:uppercase;letter-spacing:.4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-plus-filter{padding:7px 11px 2px}
.fd-plus-filter input{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:6px 8px;outline:none;box-sizing:border-box}
.fd-plus-form{padding:4px 11px 12px}
.fd-plus-form label{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:9px 0 3px}
.fd-plus-form input,.fd-plus-form textarea{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:7px 8px;outline:none;font-family:inherit;box-sizing:border-box}
.fd-plus-form textarea{min-height:110px;resize:vertical;line-height:1.5}
.fd-plus-file{display:none}
.fd-plus-chev{margin-left:auto;color:var(--text-dim,#999);opacity:.75;font-size:15px;flex-shrink:0}
.fd-plus-crumb{display:flex;gap:5px;flex-wrap:wrap;padding:7px 11px 3px;font-size:11px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
.fd-plus-crumb-it{color:var(--accent,#2383E2);cursor:pointer;white-space:nowrap}
.fd-plus-crumb-it:hover{text-decoration:underline}
/* Toast */
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
@@ -380,6 +400,64 @@ body.fd-ap-resizing *{cursor:col-resize!important}
</template>
</template>
</div>
<div class="fd-ap-mention-menu fd-ap-plus-menu" :class="{show: plusOpen}" x-ref="plusMenu">
<div class="fd-plus-head" x-show="plusSection!=='root'" x-cloak>
<button class="fd-plus-back" type="button" @click="plusBack()" title="Retour" aria-label="Retour">←</button>
<span class="fd-plus-title" x-text="plusTitle"></span>
</div>
<div class="fd-plus-filter" x-show="plusSection==='skills-gallery'" x-cloak>
<input type="search" placeholder="Filtrer les compétences…" aria-label="Filtrer les compétences" x-model="galleryFilter">
</div>
<div class="fd-plus-crumb" x-show="plusSection==='browse'">
<span class="fd-plus-crumb-it" @click="plusGoto(-1)">Racine</span>
<template x-for="(c, ci) in plusCrumb" :key="c.id">
<span class="fd-plus-crumb-it" @click="plusGoto(ci)" x-text="'› ' + c.name"></span>
</template>
</div>
<div class="fd-mention-load" x-show="plusSection==='browse' && plusLoading" x-cloak>Chargement…</div>
<div class="fd-mention-empty" x-show="plusSection==='browse' && !plusLoading && !plusItems.length" x-cloak>Dossier vide</div>
<template x-for="(it, i) in plusItems" :key="it.key">
<div>
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: i===plusFocus, disabled: !!it.disabled}"
@click="plusAction(it)" @mouseenter="it.disabled ? null : plusFocus = i">
<span class="fd-mention-ico" x-text="it.icon"></span>
<div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div>
<div class="fd-mention-sub" x-text="it.sub"></div>
</div>
<span class="fd-plus-chev" x-show="it.chev || it._folder">›</span>
</div>
</div>
</template>
<div class="fd-plus-form" x-show="plusSection==='skills-edit'" x-cloak>
<label for="fd-plus-skill-name">Nom</label>
<input id="fd-plus-skill-name" type="text" x-model="skillForm.name" placeholder="ex. synthese-reunion" @keydown.enter.prevent="saveSkill()">
<label for="fd-plus-skill-desc">Description</label>
<input id="fd-plus-skill-desc" type="text" x-model="skillForm.description" placeholder="Une phrase">
<label for="fd-plus-skill-prompt">Prompt (consigne appliquée à la demande)</label>
<textarea id="fd-plus-skill-prompt" x-model="skillForm.prompt_template" placeholder="Fais…"></textarea>
<div class="fd-proposal-bar">
<button class="fd-proposal-btn primary" type="button" @click="saveSkill()">✓ Enregistrer</button>
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="deleteSkill()" x-cloak>✕ Supprimer</button>
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="exportSkill()" x-cloak>⤓ Export</button>
<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-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">URL publique (hôtes privés refusés)</label>
<input id="fd-plus-cn-url" type="url" x-model="connectorForm.url" placeholder="https://api.exemple.com">
<label for="fd-plus-cn-secret">Clé API — stockée 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>
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
<div class="fd-chip-row" x-show="mentionChips.length">
@@ -406,7 +484,7 @@ body.fd-ap-resizing *{cursor:col-resize!important}
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
<div class="fd-ap-toolbar">
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
<button class="fd-ap-add" type="button" title="Ajouter au contexte : fichiers, répertoires, compétences…" aria-label="Menu d'ajout au contexte" aria-haspopup="menu" :aria-expanded="plusOpen" @click="toggleAddPicker()">+</button>
<span class="fd-ap-tip" x-text="composerHint"></span>
<div class="fd-ap-tools">
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
+9 -2
View File
@@ -2093,9 +2093,16 @@
case 'openTab':
window.open('/' + pageId, '_blank');
break;
case 'openPeek':
this.toast('Side peek not available in browser', 'info');
case 'openPeek': {
// Route vers le panneau de peek DE LA PAGE courante (même
// éditeur allégé `?embed=1` que le bouton « ↢ Open »), jamais une
// navigation. En dehors de ces pages : repli sur un nouvel onglet.
var num = pageId.replace('page/', '');
if (window._libData && window._libData.openPeekById) window._libData.openPeekById(parseInt(num));
else if (window._wsData && window._wsData.openSidePeek) window._wsData.openSidePeek(parseInt(num));
else window.open('/pages/' + num, '_blank');
break;
}
}
this.contextMenu.visible = false;
},
+3 -3
View File
@@ -150,7 +150,7 @@
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
.peek-header button svg{width:15px;height:15px;}
.peek-body{flex:1;overflow-y:auto;padding:16px 20px;}
.peek-body{flex:1;overflow:hidden;padding:0;}
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
@@ -450,8 +450,8 @@
<!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en
permanence, ce qui permet de piloter `src` en JS (le build CSP
d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
Charger `/pages/{id}` sans `?embed=1` : c'est exactement l'editeur
plein ecran, le peek n'est plus une variante allegee. -->
Charger `/pages/{id}?embed=1` : le peek affiche uniquement le
document en mode edition, sans sidebar ni barre de navigation. -->
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
<div class="peek-loading" id="lib-peek-loading">Chargement…</div>
<iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
+5
View File
@@ -8,4 +8,9 @@
{% endblock %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
{# database_table.js = FlowDeckDB + le panneau de peek des bases : sans cet
include, une base embarquée dans un document restait bloquée à
« Loading database… » dans le peek et l'icône Open de fin de ligne ne
faisait rien. #}
{% include '_database_table_scripts.html' %}
{% endblock %}
+210
View File
@@ -0,0 +1,210 @@
# V74 — Menu + de l'assistant : hub de contexte (design)
> **Statut** : design — **phases 1 à 5 livrées (v7.51.0 → v7.55.0, 2026-10-06)** ;
> phases 6-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'`), 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).
---
## 1. Objectif
Le bouton **+** du panneau agent (`app/templates/agent_panel.html:409`) ouvre
aujourd'hui une liste plate de fichiers/pages/bases à épingler en contexte. On le
transforme en **menu à sections à deux niveaux** :
```
+ ┌──────────────────────────────────────────────────────┐
│ 📎 Ajouter des fichiers ou répertoires › │ → picker actuel + « Parcourir… »
│ ✨ Compétences-skills › │ → Deep research, Skill-creator, …
│ │ ────────────────
│ │ Gérer les compétences
│ │ Parcourir les compétences
│ 🔌 Connecteurs › │ → Parcourir les connecteurs
│ │ Ajouter un connecteur personnalisé
│ 🎨 Design System – Canevas › │ → liste des canevas
│ 🧩 Add plugins │ → catalogue on/off (modale)
│ 🧠 Mémoire [◉/○] │ → toggle persisté
└──────────────────────────────────────────────────────┘
```
Règle d'or : **chaque entrée vit dans la même phase qui la rend réelle**. Tant qu'une
section n'est pas livrée, elle s'affiche grisée « Bientôt (phase N) » — le menu ne
promet rien que le code ne fait.
## 2. État des lieux (lu dans le code, 2026-10-06)
| Section | Existe | Manque |
|---|---|---|
| Fichiers / répertoires | `GET /api/agent/mentions` (`app/routers/agent.py:596`) → documents, collections, pages ; jetons `document:id`, `collection:id`, `page:id` résolus par `ContextBuilder._mentions_context` (`app/services/context_builder.py:120`) | Sous-menu, mode parcours d'arborescence, jeton `folder:<id>` |
| Compétences | `agent_skills` CRUD (`/api/agent/skills` : GET/POST/DELETE, apply, import/export), galerie de 17 presets (`app/services/skill_gallery.py`), 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) | UI « Gérer » (CRUD complet : **`PATCH /skills/{id}` à créer**), UI « Parcourir » (la galerie n'est accessible que via la palette `/`), libellés affichés type « Deep research » |
| Canevas | `GET /api/page-templates` (built-ins `app/services/block_templates.py` + personnels), `POST /api/page-templates/{id}/use` (`app/routers/board/page_api.py:75,135`) | Menu dans le panneau agent, action « insérer dans le document ouvert », canevas « design system » |
| Connecteurs | Rien (0 hit `connector` dans `app/`). Natifs déjà branchés ailleurs : Gitea (`gitea_client.py`), GitHub (`github_adapter.py`), Web (`web_search.py` + tool `fetch_url` v7.46) | Table, service d'adapters, catalogue UI, tools agent, OAuth Google/M365, Discord/Telegram, MCP |
| Plugins | Rien | Registre, catalogue UI, **câblage réel** (désactivation effective) |
| Mémoire | Rien (l'historique de la conversation est déjà envoyé, ce n'est pas de la mémoire) | Table, service d'extraction, injection conditionnelle, toggle persisté |
## 3. Découpage front (Alpine)
Tout vit dans `static/js/agent_panel_2.js` (composant `agentPanel()`) + un bloc de
markup dans `app/templates/agent_panel.html`, sans nouveau fichier JS.
**État ajouté**
```js
plusOpen: false, // menu ouvert
plusSection: 'root', // 'root' | 'files' | 'skills' | 'connectors' | 'canvases'
plusFocus: -1, // index de l'item focalisé
plusItems: [], // items de la section courante (fetch si besoin)
memoryOn: true, // état du toggle (phase 4)
```
**Items = données, pas du markup en dur** : un tableau `FD_PLUS_MENU`
`{key, icon, label, sub, action, phase}` → le rendu et la navigation clavier
(↑/↓/Entrée/Échap) sont une seule boucle. Les sections `phase > livrée` sont
`disabled: true`.
**Réemploi** : le conteneur et les styles `.fd-ap-mention-menu` /
`.fd-mention-item` (`agent_panel.html:171-181`) servent déjà exactement ce
comportement — on duplique le CSS minimal (`.fd-plus-*`) et on garde la logique de
focus/scroll (`moveMentionFocus`, `_scrollMentionItem`) comme modèle.
**Entrées existantes préservées** : la touche `@` dans le composer et le mode
recherche du `+` continuent de fonctionner à l'identique (régression zéro) ; le `+`
devient seulement *l'entrée en menu* au lieu d'ouvrir directement la liste.
## 4. Backend par phase
| Phase | Existant réutilisé | À créer |
|---|---|---|
| 1 — menu + fichiers/répertoires | `/api/agent/mentions`, `/api/local-workspace/tree?folder=`, enfants de page (`parent_id`, `local_workspace.py:243`) | Route(s) de parcours adaptée(s) au panneau (shape items compatible mention), jeton `folder:<id>` dans `ContextBuilder` |
| 2 — compétences | `/api/agent/skills` (GET/POST/DELETE/apply/import/export), `/api/agent/skills/gallery` + install | `PATCH /api/agent/skills/{id}` (édition), 2 modales |
| 3 — canevas | `/api/page-templates` (GET/POST/use) | Insersion dans l'éditeur ouvert (API d'insertion de blocs de l'éditeur), presets « design system » dans `block_templates.py` |
| 4 — mémoire | `ContextBuilder`, `PATCH /conversations/{id}` (pattern du toggle) | Table `agent_memory`, `app/services/agent_memory.py`, colonne `conversations.memory_enabled`, config `AGENT_MEMORY_DEFAULT` |
| 5 — connecteurs (socle) | `_is_public_host` (SSRF), `encrypt_secret` (`sso_provisioning.py:51`), `http_client.shared_client`, `tool_registry` | Table `agent_connectors`, `app/services/connectors/`, 3 adapters natifs (gitea/github/web), catalogue UI |
| 6 — Google + M365 | `auth/oauth.py` (client OAuth2), `encrypt_secret`, `shared_client` | Flows PKCE Google + Microsoft Graph, table de tokens (motif `calendar_sync.py:38`), écran connecteur |
| 7 — Discord / Telegram / Teams / MCP | Graph (phase 6), pattern de tools dynamiques du registre | 3 adapters + client MCP (`initialize`, `tools/list`, `tools/call`) |
| 8 — plugins | — | Table `plugins`, catalogue UI, désactivation réelle (routes + UI) |
## 5. Modèle de données
```sql
-- Phase 4 — mémoire
ALTER TABLE conversations ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1;
CREATE TABLE agent_memory (
id INTEGER PRIMARY KEY,
workspace_id INTEGER NOT NULL,
conversation_id INTEGER, -- NULL = mémoire d'espace (partagée)
kind TEXT NOT NULL DEFAULT 'summary', -- summary | fact
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_agent_memory_ws ON agent_memory(workspace_id, conversation_id);
-- Phase 5 — connecteurs
CREATE TABLE agent_connectors (
id INTEGER PRIMARY KEY,
workspace_id INTEGER,
kind TEXT NOT NULL, -- gitea | github | web | google | ms365 | discord | telegram | mcp | custom
name TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}', -- URL, scopes, channels…
secret_encrypted TEXT, -- Fernet (encrypt_secret, sso_provisioning.py:51)
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'unknown', -- ok | error | unknown
last_probe_at TEXT,
created_by INTEGER
);
-- Phase 8 — plugins
CREATE TABLE plugins (
slug TEXT PRIMARY KEY, -- web-tools | web-clipper | automations | …
enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL
);
```
Migrations ajoutées dans `app/migrations.py` (motif `_migration_*` existant,
transaction par migration — A31).
## 6. Sécurité (non négociable)
- **SSRF** : toute URL de connecteur passée par `_is_public_host`
(`app/services/importers/url_fetch.py`), re-vérifiée à chaque saut de redirection.
- **Secrets** : jamais en clair, jamais renvoyés par l'API (champ retiré des
sérialiseurs) — `encrypt_secret()` existant, dérivé de `app_secret_key`.
- **Auth + CSRF** : session requise sur tout CUD ; `/api/agent` est sur la liste des
préfixes CSRF cookie → header `X-CSRF-Token` sur les nouveaux `fetch` (helper
`getCsrf()`).
- **Borne de tokens** : la mémoire et les sources de connecteurs injectées dans le
contexte sont tronquées (budget explicite) — sinon la phase 4 fait exploser le
coût de chaque run.
- **Plugins OFF = effet réel** : un simple drapeau affiché serait un menu qui ment
(règle du projet).
## 7. Décisions techniques
- **D1 — Un seul menu, deux niveaux** (pas de modale racine) : ouverture instantanée,
clavier, et le `+` reste un geste unique. Les modales ne servent qu'aux écrans
lourds (Gérer / Parcourir / Connecteurs / Plugins).
- **D2 — Pas de nouveau fichier JS** : tout dans `agent_panel_2.js` + markup du
template, conforme à l'extraction A27 (un fichier par bloc).
- **D3 — États « Bientôt » visibles** plutôt que sections masquées : le menu sert de
spec vivante et évite d'implémenter6 features d'un coup.
- **D4 — La mémoire est un résumé structuré, pas l'historique brut** : réinvoquer
tout l'historique à chaque run coûterait plus cher que la réponse ; un résumé borné
(~1–2 k tokens) + les faits saillants suffisent, avec repli déterministe hors-ligne
(motif `ai_writing.py`).
- **D5 — Connecteurs = registry + adapters** (interface `probe/list_sources/fetch`),
pas un if par provider : la phase 7 (MCP) ajoute des adapters sans toucher au socle.
- **D6 — Canevas : « créer » par défaut, « insérer » seulement si un document est
ouvert** — l'insertion dans l'éditeur est le point le plus fragile (blocs +
sélection), donc livrée en second dans la phase 3.
- **D7 — Pas de dépendance ajoutée** : OAuth via `httpx` (`shared_client`), chiffrement
via `cryptography` déjà présent, MCP en HTTP direct.
## 8. Pièges du repo à respecter
- `ROADMAP.md` / `CHANGELOG.md` sont en **CRLF** : `patch` exige des lignes complètes.
- **Routes statiques avant les wildcards** (`/skills/gallery` avant `/skills/{id}`),
sinon 422 sur un id non numérique.
- **Events `AgentEngine.run()` plats** (`{"type": …, "content": …}`), pas enveloppés
dans `data`.
- Bumper **`VERSION` + `app/main.py`** à chaque phase, régénérer
`docs/openapi-v2.json` (CRLF, sans newline final) si de nouvelles routes sortent.
- Le panneau agent est rendu sur **toutes les pages** (`base.html:2555`) : le JS doit
rester défensif (`window.appState` absent, page non authentifiée).
## 9. Critères d'acceptation par phase
1. **Phase 1** : le `+` ouvre le menu, « Parcourir… » montre l'arborescence, un
dossier épinglé apparaît en chip et son contenu est bien dans le contexte du run.
2. **Phase 2** : créer/éditer/supprimer un skill depuis la modale, l'installer depuis
la galerie, il apparaît dans la palette `/` et le sous-menu.
3. **Phase 3** : choisir un canevas crée la page (et l'insertion dans le document
ouvert fonctionne quand un doc est édité).
4. **Phase 4** : ON → la mémoire est injectée (test qui le prouve) ; OFF → absente ;
l'état survit au rechargement et au changement de conversation.
5. **Phase 5** : connecteur créé/testé/supprimé, URL privée refusée (SSRF), tool
exposé au LLM avec le bon schéma.
6. **Phase 6** : connexion Google et Microsoft, refresh, déconnexion, aucun appel
réseau réel dans les tests.
7. **Phase 7** : Discord/Telegram branchés, MCP `tools/list` → outils visibles dans le
registre.
8. **Phase 8** : plugin OFF = route refusée + UI disparue (test de non-réintroduction).
Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, ROADMAP
à jour, tag.
## 10. Hors périmètre
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
- Synchronisation d'arborescence locale ↔ connecteur.
- Marketplace de plugins tiers (le registre est interne à l'instance).
- Refonte du composer / de la palette `/` (compatibilité maintenue à l'identique).
+298 -8
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.49.2"
"version": "7.55.0"
},
"paths": {
"/auth/register": {
@@ -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": [
@@ -15906,6 +15957,96 @@
}
}
},
"/api/agent/skills/{skill_id}": {
"patch": {
"tags": [
"agent"
],
"summary": "Update Skill",
"description": "Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).\n\nSeuls les champs présents dans ``body`` sont mis à jour ; la liste des\ncolonnes est figée ici (jamais interpolée depuis la requête).",
"operationId": "update_skill_api_agent_skills__skill_id__patch",
"parameters": [
{
"name": "skill_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Skill 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 Skill",
"operationId": "delete_skill_api_agent_skills__skill_id__delete",
"parameters": [
{
"name": "skill_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Skill Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/skills/{skill_id}/apply": {
"post": {
"tags": [
@@ -16099,24 +16240,173 @@
}
}
},
"/api/agent/skills/{skill_id}": {
"delete": {
"/api/agent/connectors": {
"get": {
"tags": [
"agent"
],
"summary": "Delete Skill",
"operationId": "delete_skill_api_agent_skills__skill_id__delete",
"summary": "List Connectors Route",
"description": "Catalogue : 3 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/connectors/{connector_id}": {
"patch": {
"tags": [
"agent"
],
"summary": "Update Connectors Route",
"operationId": "update_connectors_route_api_agent_connectors__connector_id__patch",
"parameters": [
{
"name": "skill_id",
"name": "connector_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Skill Id"
"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",
@@ -29889,4 +30179,4 @@
}
}
}
}
}
+7 -6
View File
@@ -2246,13 +2246,15 @@ button.breadcrumb-current { max-width: 260px; }
/* Page de base (premier bloc = embed collection) : le titre et le tableau
s'alignent sur la marge gauche de la topbar (16px) au lieu d'être centrés
dans la colonne 900px — le tableau démarre au ras du sidebar. */
dans la colonne 900px — le tableau démarre au ras du sidebar et prend la
LARGEUR DU DOCUMENT (les colonnes dictent le scroll horizontal). */
.page-editor-wrapper.db-first .page-cover-area,
.page-editor-wrapper.db-first .page-title-block,
.page-editor-wrapper.db-first .blocks-container {
max-width: 1100px;
max-width: 100%;
margin: 0;
padding-left: var(--space-lg);
padding-right: var(--space-lg);
}
/* Une base embarquée ne doit jamais déborder de sa colonne de blocs :
@@ -2262,7 +2264,7 @@ button.breadcrumb-current { max-width: 260px; }
/* Titre des pages de base dédiées (page_editor_collection.html) — même
liseré gauche que .database-table-container. */
.database-page-title {
max-width: 1100px;
max-width: 100%;
margin: 0;
padding: 28px var(--space-lg) 0;
font-size: 30px;
@@ -3899,11 +3901,10 @@ button.breadcrumb-current { max-width: 260px; }
═══════════════════════════════════════════════════════════ */
/* Base = viewport du contenu ; les vues tabulaires larges (bases de
données) s'alignent à gauche, au ras de la marge, à la même largeur
que Library (1100px). */
données) prennent la LARGEUR DU DOCUMENT (les colonnes dictent le scroll
horizontal), alignées à gauche au ras de la marge. */
.database-table-container {
width: 100%;
max-width: 1100px;
margin: 0;
/* var(--space-lg) = padding de .topbar : le bord gauche du tableau
s'aligne sur le titre du fil d'ariane, au ras de la marge. */
+8
View File
@@ -207,6 +207,14 @@
}
.mt-card:active { cursor: grabbing; opacity: .7; }
.mt-card-title { font-weight: 500; }
/* Bouton « Open » en overlay au survol de la tuile (la carte est une
colonne flex : une place en flux déformerait la tuile). */
.mt-card { position: relative; }
.mt-card .mt-open-btn {
position: absolute; top: 6px; right: 6px; margin-left: 0;
}
.mt-card:hover .mt-hover-only,
.mt-card:focus-within .mt-hover-only { opacity: 1; }
.mt-card-due,
.mt-card-src { font-size: 11px; color: var(--text-dim, #999); }
.mt-card-src { opacity: .8; }
+611 -6
View File
@@ -32,6 +32,30 @@
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack',
template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'}
];
// ── Menu + (v7.51.0) : sections à deux niveaux. `soon` = section pas encore
// livrée → affichée mais non cliquable (le menu ne promet rien que le code ne fait).
var FD_PLUS_MENU = [
{key:'files', icon:'📎', label:'Ajouter des fichiers ou répertoires',
sub:'Rechercher ou parcourir le workspace', action:'files', chev:true},
{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 les connecteurs · ajouter un personnalisé', action:'connectors', chev:true},
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
{key:'plugins', icon:'🧩', label:'Add plugins',
sub:'Modules on/off — bientôt (phase 8)', disabled:true},
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
sub:'Résumés des échanges injectés à chaque run', action:'memory'}
];
var FD_PLUS_FILES = [
{key:'f-search', icon:'🔍', label:'Rechercher…',
sub:'Documents, bases, pages par nom', action:'search', chev:true},
{key:'f-browse', icon:'📁', label:'Parcourir…',
sub:'Dossiers et fichiers du workspace', action:'browse', chev:true}
];
// Libellés affichés des skills builtin (le slug /commande reste inchangé).
var FD_SKILL_ALIAS = {'research': 'Deep research', 'create-new-skill': 'Skill-creator'};
function agentPanel(){
return {
@@ -45,6 +69,13 @@
contextChips: [], skills: [], gallery: [],
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
plusOpen: false, plusSection: 'root', plusFocus: -1, plusLoading: false,
plusBrowse: [], plusCrumb: [],
skillForm: {id: null, name: '', description: '', prompt_template: ''},
galleryFilter: '',
canvases: [], canvas: null,
connectors: [], connector: null,
connectorForm: {name: '', url: '', secret: ''},
cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null,
_livePh: null,
@@ -469,7 +500,8 @@
var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨',
label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id};
this.contextChips.push(pin);
this.clearComposer();
// Ne jamais effacer un message déjà tapé (le token s'ajoute à la fin).
if(!this.composerText().trim()) this.clearComposer();
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
},
@@ -616,7 +648,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;
@@ -633,7 +666,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;
@@ -1070,12 +1104,569 @@
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
},
// ── Mentions & contexte épinglé (@ / +) ──
// ── Menu + (sections) & mentions (@ / recherche) ──
toggleAddPicker(){
if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; }
this.openMentionPicker('+');
if(this.mentionOpen){
this.mentionOpen = false; this.mentionQuery = '';
this._mentionFrom = null; this._atNode = null;
}
if(this.plusOpen){ this.closePlus(); return; }
this.plusOpen = true;
this.plusSection = 'root';
this.plusFocus = 0;
},
closePlus(){
this.plusOpen = false;
this.plusSection = 'root';
this.plusCrumb = [];
this.plusBrowse = [];
this.plusFocus = -1;
},
get plusTitle(){
var s = this.plusSection;
if(s === 'files') return 'Fichiers & répertoires';
if(s === 'browse') return 'Parcourir le workspace';
if(s === 'skills') return 'Compétences-skills';
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';
return 'Ajouter au contexte';
},
get plusItems(){
var s = this.plusSection;
if(s === 'files') return FD_PLUS_FILES;
if(s === 'browse') return this.plusBrowse;
if(s === 'skills') return this._skillsItems();
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 === '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 = [];
FD_SKILLS.forEach(function(c){
var slug = String(c.cmd).replace(/^\//, '');
out.push({key:'bi-'+slug, icon:c.icon, label:FD_SKILL_ALIAS[slug] || c.cmd,
sub:c.desc, action:'skill',
suggestion:{key:'bi-'+slug, cmd:c.cmd, icon:c.icon, template:c.template, _type:'builtin'}});
});
(this.skills || []).forEach(function(sk){
out.push({key:'db-'+sk.id, icon:'✨', label:sk.name,
sub:sk.description || 'Skill enregistré', action:'skill',
suggestion:{key:'db-'+sk.id, cmd:'/'+sk.name, icon:'✨', _type:'db', skillId:sk.id}});
});
out.push({key:'sep-actions', _sep:'Gérer / parcourir'});
out.push({key:'a-manage', icon:'🛠️', label:'Gérer les compétences',
sub:'Créer, modifier, supprimer, exporter / importer', action:'skills-manage', chev:true});
out.push({key:'a-browse', icon:'🗂️', label:'Parcourir les compétences',
sub:'Galerie de presets installables en 1 clic', action:'skills-gallery', chev:true});
return out;
},
// Sous-section Gérer : liste des skills + création + import.
_manageItems(){
var out = [];
(this.skills || []).forEach(function(sk){
out.push({key:'m-'+sk.id, icon:'✨', label:sk.name,
sub:sk.description || 'Cliquer pour modifier', action:'skill-edit', chev:true, skillId:sk.id});
});
if(!out.length) out.push({key:'m-none', _sep:'Aucun skill enregistré'});
out.push({key:'m-new', icon:'+', label:'Nouveau skill',
sub:'Nom, description, prompt', action:'skill-new', chev:true});
out.push({key:'m-sep', _sep:'Import'});
out.push({key:'m-import', icon:'📥', label:'Importer un skill (JSON)',
sub:'Document exporté depuis une autre instance', action:'skill-import'});
return out;
},
// Sous-section Parcourir : presets de la galerie, filtrables.
_galleryItems(){
var f = String(this.galleryFilter || '').toLowerCase();
var out = [];
(this.gallery || []).forEach(function(g){
var hay = (String(g.name || '') + ' ' + (g.description || '') + ' ' + (g.slug || '')).toLowerCase();
if(f && hay.indexOf(f) < 0) return;
out.push({key:'g-'+g.slug, icon:g.icon || '📦', label:g.name,
sub:g.description || 'Preset de la galerie', action:'skill-install',
slug:g.slug, skillName:g.name});
});
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' ? '⚠ ' : '? '));
return mark + (c.detail || c.status) + (c.enabled ? '' : ' · désactivé');
},
_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.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); });
},
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); });
},
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 || ''})
}).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); });
return out;
},
movePlusFocus(delta){
var sel = this._plusSelectable();
if(!sel.length) return;
var idx = sel.indexOf(this.plusFocus);
if(idx < 0) idx = delta > 0 ? -1 : 0;
this.plusFocus = sel[(idx + delta + sel.length) % sel.length];
this._scrollPlusItem();
},
_scrollPlusItem(){
var menu = this.$refs && this.$refs.plusMenu;
if(!menu) return;
var el = menu.querySelector('.fd-mention-item.focus');
if(el && el.scrollIntoView){ menu.scrollTop = el.offsetTop - menu.clientHeight / 2 + el.clientHeight / 2; }
},
plusAction(it){
if(!it || it.disabled || it._sep) return;
if(it.action === 'files'){ this.plusSection = 'files'; this.plusFocus = 0; return; }
if(it.action === 'search'){ this.closePlus(); this.openMentionPicker('+'); return; }
if(it.action === 'browse'){
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'){
this.plusSection = 'skills-manage'; this.plusFocus = 0; return;
}
if(it.action === 'skills-gallery'){
this.galleryFilter = ''; this.fetchSkills();
this.plusSection = 'skills-gallery'; this.plusFocus = 0; return;
}
if(it.action === 'skill-new'){
this.skillForm = {id:null, name:'', description:'', prompt_template:''};
this.plusSection = 'skills-edit'; this.plusFocus = -1; return;
}
if(it.action === 'skill-edit'){
var sk = null, wanted = it.skillId;
(this.skills || []).forEach(function(x){ if(x.id === wanted) sk = x; });
if(!sk) return;
this.skillForm = {id: sk.id, name: sk.name, description: sk.description || '',
prompt_template: sk.prompt_template || ''};
this.plusSection = 'skills-edit'; this.plusFocus = -1; return;
}
if(it.action === 'skill-install'){
this.installGallerySkill(it.slug, it.icon, it.skillName); return;
}
if(it.action === 'skill-import'){
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:''};
this.plusSection = 'connector-new'; this.plusFocus = -1; 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; }
// ── 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);
return;
}
if(it.token){ this._mentionFrom = null; this._atNode = null; this.closePlus(); this.pickMention(it); return; }
this.closePlus(); // sections futures (phases 2-8)
},
plusBack(){
var sec = this.plusSection;
if(sec === 'browse' && this.plusCrumb.length){
this.plusCrumb.pop();
this.plusLoadBrowse(this.plusCrumb.length ? this.plusCrumb[this.plusCrumb.length - 1].id : null);
return;
}
if(sec === 'skills-edit'){ this.plusSection = 'skills-manage'; this.plusFocus = 0; return; }
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;
},
plusGoto(i){ // fil d'Ariane : -1 = racine
this.plusCrumb = i < 0 ? [] : this.plusCrumb.slice(0, i + 1);
this.plusLoadBrowse(i < 0 ? null : this.plusCrumb[this.plusCrumb.length - 1].id);
},
plusLoadBrowse(parentId){
// Un niveau par appel : /api/nav/menu (dossier = icon 'folder').
var self = this;
this.plusLoading = true;
fetch('/api/nav/menu' + (parentId ? ('?parent_id=' + parentId) : ''))
.then(function(r){ return r.json(); })
.then(function(d){
var items = [];
// icon de /api/nav/menu = nom SVG ('folder'|'edit'|'image'|'file') → emoji
var ICONS = {folder:'📁', edit:'📄', image:'🖼️', file:'📎'};
(d.items || []).forEach(function(x){
var isFolder = x.icon === 'folder';
items.push({
key: 'br-' + x.id, icon: ICONS[x.icon] || '📄',
label: x.name, sub: isFolder ? 'Dossier — ouvrir' : 'Document — épingler',
token: (isFolder ? 'folder:' : 'document:') + x.id,
type: isFolder ? 'folder' : 'document', id: x.id,
_folder: isFolder ? x.id : null
});
});
if(self.plusCrumb.length){
var cur = self.plusCrumb[self.plusCrumb.length - 1];
items.unshift({key: 'pin', icon: '📌',
label: 'Épingler le dossier « ' + cur.name + ' »',
sub: 'Ajoute le dossier entier au contexte',
token: 'folder:' + cur.id, type: 'folder', id: cur.id});
}
self.plusBrowse = items;
self.plusLoading = false;
self.plusFocus = items.length ? 0 : -1;
self.$nextTick(function(){ self._scrollPlusItem(); });
})
.catch(function(){ self.plusBrowse = []; self.plusLoading = false; self.plusFocus = -1; });
},
// ── Gérer les compétences : CRUD / export / import (v7.52.0) ──
saveSkill(){
var self = this, f = this.skillForm;
if(!(f.name || '').trim()){ this.toast('Le nom est requis.', true); return; }
fetch(f.id ? '/api/agent/skills/' + f.id : '/api/agent/skills', {
method: f.id ? 'PATCH' : 'POST',
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: f.name, description: f.description || '',
prompt_template: f.prompt_template || ''})
}).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('Skill enregistré.');
self.fetchSkills();
self.plusSection = 'skills-manage'; self.plusFocus = 0;
}).catch(function(){ self.toast('Enregistrement impossible (réseau).', true); });
},
deleteSkill(){
var self = this, f = this.skillForm;
if(!f.id) return;
if(!window.confirm('Supprimer le skill « ' + f.name + ' » ?')) return;
fetch('/api/agent/skills/' + f.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((res.d && res.d.detail) || 'Suppression impossible.', true); return; }
self.toast('Skill supprimé.');
// le chip épinglé ne doit pas survivre au skill
self.contextChips = self.contextChips.filter(function(ch){
return !(ch.kind === 'skill' && ch.skillId === f.id);
});
self.skillForm = {id: null, name: '', description: '', prompt_template: ''};
self.fetchSkills();
self.plusSection = 'skills-manage'; self.plusFocus = 0;
}).catch(function(){ self.toast('Suppression impossible (réseau).', true); });
},
exportSkill(){
var self = this, f = this.skillForm;
if(!f.id) return;
fetch('/api/agent/skills/' + f.id + '/export')
.then(function(r){ return r.json(); })
.then(function(doc){
var blob = new Blob([JSON.stringify(doc, null, 2)], {type: 'application/json'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = String((doc && doc.name) || 'skill').replace(/[^\w.-]+/g, '-') + '.json';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(function(){ URL.revokeObjectURL(a.href); }, 1000);
}).catch(function(){ self.toast('Export impossible.', true); });
},
importSkillFile(ev){
var self = this;
var input = ev.target, file = input.files && input.files[0];
if(!file) return;
file.text().then(function(txt){
var payload;
try{ payload = JSON.parse(txt); }
catch{ self.toast('Fichier JSON invalide.', true); return; }
return fetch('/api/agent/skills/import', {
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({payload: payload, overwrite: true})
}).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) || 'Import impossible.', true); return; }
self.toast('Skill importé : ' + ((res.d && res.d.name) || '') + ' (pas de doublon).');
self.fetchSkills();
self.plusSection = 'skills-manage'; self.plusFocus = 0;
});
}).catch(function(){ self.toast('Lecture du fichier impossible.', true); })
.finally(function(){ input.value = ''; });
},
openMentionPicker(from){
this.plusOpen = false;
this.mentionOpen = true;
this._mentionFrom = from || '+';
this.mentionQuery = '';
@@ -1177,6 +1768,7 @@
onInput(){
this.resizeInput();
this.slashDismiss = false;
if(this.plusOpen) this.closePlus(); // tape = on écrit le message, le menu se referme
var text = this.composerText();
this.input = text;
var cmdNow = /^\s*\//.test(text);
@@ -1249,16 +1841,29 @@
}catch { /* volontaire */ }
}
if(key === 'Escape'){
if(this.plusOpen){ e.preventDefault(); e.stopPropagation(); this.closePlus(); return; }
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
return; // laisse fermer le panneau
}
if(key === 'ArrowDown' || key === 'ArrowUp'){
var delta = key === 'ArrowDown' ? 1 : -1;
if(this.plusOpen){ e.preventDefault(); this.movePlusFocus(delta); return; }
if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
return;
}
// Entrée / Tab avec le menu + ouvert
if(this.plusOpen){
if(key === 'Enter' || key === 'Tab'){
e.preventDefault();
var psel = this._plusSelectable();
if(!psel.length) return;
var pidx = psel.indexOf(this.plusFocus) >= 0 ? this.plusFocus : psel[0];
this.plusAction(this.plusItems[pidx]);
return;
}
}
// Entrée / Tab avec un menu ouvert (skills / mentions)
if(this.mentionOpen){
var mSel = this._selectableMentionItems();
+75 -61
View File
@@ -1283,72 +1283,86 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
}
// ── Side peek : panneau latéral des lignes de base (vanilla JS) ──
// Le panneau vit dans _database_table.html (page de base ET bases
// embarquées inline). Il doit être câblé SUR le document, jamais via
// container.__x : le conteneur n'a pas de x-data (les bindings Alpine
// du panneau restaient inertes — clic sur une ligne = rien).
var peekEl = document.getElementById('db-side-peek');
// Sans panneau (collections embarquées inline dans l'éditeur), le clic ne
// doit pas lever de ReferenceError : repli discret, comme l'ancien code.
if (!peekEl) window.openPageInSidePeek = function() {};
if (peekEl) {
// Le panneau vit dans _database_table.html (page de base). Une base
// EMBARQUÉE dans un document n'a pas ce markup : on le crée à la demande
// (comme mt-peek de My Tasks), sinon l'icône Open de fin de ligne était
// un no-op silencieux. Il doit être câblé SUR le document, jamais via
// container.__x : le conteneur n'a pas de x-data.
var PEEK_HTML =
'<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>' +
'<div class="db-side-peek-header">' +
'<button id="db-side-peek-close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>' +
'<div class="db-side-peek-title" id="db-side-peek-title"></div>' +
'<button id="db-side-peek-full" title="Open full page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>' +
'</div>' +
'<div class="db-side-peek-body">' +
'<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>' +
'<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>' +
'</div>';
function ensureDbPeek() {
var p = document.getElementById('db-side-peek');
if (p) return p;
p = document.createElement('div');
p.id = 'db-side-peek';
p.className = 'db-side-peek';
p.innerHTML = PEEK_HTML;
document.body.appendChild(p);
document.getElementById('db-side-peek-close').addEventListener('click', closeDbPeek);
document.getElementById('db-side-peek-full').addEventListener('click', function () {
var f = document.getElementById('db-side-peek-iframe');
// `dataset.page` et non `iframe.src` : le src porte `?embed=1`,
// la pleine page doit garder sa sidebar et sa barre.
if (f && f.dataset.page) window.location.href = '/pages/' + f.dataset.page;
});
if (window.fdWirePeekResize) window.fdWirePeekResize(p, p.querySelector('.peek-resize-handle'), closeDbPeek);
return p;
}
function closeDbPeek() {
var p = document.getElementById('db-side-peek');
if (!p) return;
p.classList.remove('open');
document.getElementById('db-side-peek-iframe').src='about:blank';
var t = document.getElementById('db-side-peek-title');
if (t) t.textContent = '';
}
window.openPageInSidePeek = function(pageId) {
if (!pageId) return;
var peekEl = ensureDbPeek();
var peekFrame = document.getElementById('db-side-peek-iframe');
var peekTitle = document.getElementById('db-side-peek-title');
var peekLoading = document.getElementById('db-side-peek-loading');
function closeDbPeek() {
peekEl.classList.remove('open');
document.getElementById('db-side-peek-iframe').src='about:blank';
if (peekTitle) { peekTitle.textContent = ''; }
}
window.openPageInSidePeek = function(pageId) {
if (!pageId) return;
peekEl.classList.add('open');
if (peekLoading) peekLoading.style.display = '';
var iframe = document.getElementById('db-side-peek-iframe');
if (iframe) iframe.src='about:blank';
if (peekTitle) peekTitle.textContent = '';
resolveRowContentPage(pageId).then(function(pid){
if (!peekEl.classList.contains('open')) return;
if (peekTitle) peekTitle.textContent = '';
if (peekLoading) peekLoading.style.display = 'none';
if (iframe) iframe.src = '/pages/'+pid;
})
.catch(function(){
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
// CSP d'Alpine les toasts ne s'affichent pas partout.
if (iframe) {
iframe.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
}
peekEl.classList.add('open');
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
});
};
var pc = document.getElementById('db-side-peek-close');
if (pc) pc.addEventListener('click', closeDbPeek);
var pf = document.getElementById('db-side-peek-full');
if (pf) pf.addEventListener('click', function() {
if (peekFrame && peekFrame.src && peekFrame.src !== 'about:blank') {
window.location.href = peekFrame.src;
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && peekEl.classList.contains('open')) closeDbPeek();
});
document.addEventListener('pointerdown', function(e) {
peekEl.classList.add('open');
if (peekLoading) peekLoading.style.display = '';
if (peekFrame) peekFrame.src = 'about:blank';
if (peekTitle) peekTitle.textContent = '';
resolveRowContentPage(pageId).then(function(pid){
if (!peekEl.classList.contains('open')) return;
if (peekEl.contains(e.target)) return;
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
closeDbPeek();
});
function wireDbPeekResize() {
if (window.fdWirePeekResize) {
window.fdWirePeekResize(peekEl, peekEl.querySelector('.peek-resize-handle'), closeDbPeek);
} else if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireDbPeekResize);
if (peekLoading) peekLoading.style.display = 'none';
// `?embed=1` : uniquement le document edite, pas l'habillage
// (sidebar + barre) — la pleine page reste via le bouton « full ».
if (peekFrame) { peekFrame.dataset.page = pid; peekFrame.src = '/pages/'+pid+'?embed=1'; }
})
.catch(function(){
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
// CSP d'Alpine les toasts ne s'affichent pas partout.
if (peekFrame) {
peekFrame.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
}
}
wireDbPeekResize();
}
peekEl.classList.add('open');
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
});
};
document.addEventListener('keydown', function(e) {
var p = document.getElementById('db-side-peek');
if (e.key === 'Escape' && p && p.classList.contains('open')) closeDbPeek();
});
document.addEventListener('pointerdown', function(e) {
var p = document.getElementById('db-side-peek');
if (!p || !p.classList.contains('open')) return;
if (p.contains(e.target)) return;
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
closeDbPeek();
});
function resolveRowContentPage(rowId){
return fetch('/db/pages/'+rowId+'/open/api')
+3 -3
View File
@@ -979,9 +979,9 @@ _renderIcon(item) {
if (loading) loading.style.display = '';
frame.onload = function () { if (loading) loading.style.display = 'none'; };
frame.src = 'about:blank';
// `/pages/{id}` sans `?embed=1` : le peek affiche le MÊME éditeur que la
// page pleine (sidebar, barre, actions compris), pas une variante.
frame.src = '/pages/' + item.id;
// `?embed=1` : le peek affiche SEULEMENT le document en mode édition
// (ni sidebar ni barre de navigation) — le cadre du panneau suffit.
frame.src = '/pages/' + item.id + '?embed=1';
},
closePeek() {
+5 -5
View File
@@ -395,12 +395,12 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
window.showToast('Failed to copy', 'error');
});
};
// Load the full-screen editor in the iframe (no `?embed=1`: the peek
// shows the same editor as the page itself). `about:blank` first, so
// opening another document really reloads the frame instead of keeping
// the previous one when the URL happens to be identical.
// Load ONLY the editable document in the iframe (`?embed=1`: no
// sidebar, no navbar). `about:blank` first, so opening another
// document really reloads the frame instead of keeping the previous
// one when the URL happens to be identical.
var iframe = document.getElementById('ws-preview-iframe');
if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id; }
if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id + '?embed=1'; }
// Show panel
panel.style.display = 'flex';
requestAnimationFrame(function() {
+12 -6
View File
@@ -152,9 +152,10 @@ function flowDeckMyTasks() {
f.style.display = '';
// `about:blank` d'abord : ouvrir une autre tâche doit REcharger
// l'éditeur, sinon l'URL identique laisse le document précédent
// affiché. `/pages/{id}` sans `?embed=1` = l'éditeur plein écran.
// affiché. `?embed=1` = le document en édition seule, sans
// sidebar ni barre de navigation (le cadre du panneau suffit).
f.src = 'about:blank';
f.src = '/pages/' + pid;
f.src = '/pages/' + pid + '?embed=1';
}).catch(function (e) {
if (loading) {
loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message;
@@ -389,6 +390,7 @@ function flowDeckMyTasks() {
h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' +
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
'<span class="mt-card-title">' + esc(t.title) + '</span>' +
openButton(t) +
(t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' +
esc(t.due) + '</span>' : '') +
'<span class="mt-card-src">' + esc(t.collection_icon) + ' ' +
@@ -878,17 +880,21 @@ function flowDeckMyTasks() {
var t = e.target;
if (t.id === 'mt-new-btn') { openNewModal(); return; }
if (t.dataset && t.dataset.openRow) {
var opener = t.closest ? t.closest('[data-open-row]') : null;
if (opener) {
// `closest` : dans le calendrier le bouton ne contient que son
// SVG — un clic sur l'icone donnait `e.target` = <svg> sans
// dataset, et le bouton ne faisait rien.
e.preventDefault();
// Le titre vient de l'attribut : il est disponible aussi bien dans
// le tableau que dans le calendrier ou la vue Jour.
var label = t.dataset.openTitle;
var label = opener.dataset.openTitle;
if (!label) {
var row = t.closest('.mt-row');
var row = opener.closest('.mt-row');
var cell = row && row.querySelector('.mt-title-text');
label = cell ? cell.textContent : '';
}
openRowContent(Number(t.dataset.openRow), label);
openRowContent(Number(opener.dataset.openRow), label);
return;
}
if (t.dataset && t.dataset.fixSource) {
+80 -16
View File
@@ -1388,13 +1388,35 @@ def test_library_peek_iframe_is_not_inside_the_x_if_template():
assert at > html.index("</template>"), "l'iframe doit être hors du x-if"
def test_library_peek_loads_the_full_screen_editor():
"""Le peek charge `/pages/{id}` SANS `?embed=1` : exactement l'éditeur
de la page entière (le mode embed masquait sidebar, barre et actions)."""
def test_library_peek_loads_the_embed_editor_only():
"""Le peek charge `/pages/{id}?embed=1` : le document en mode édition
SEUL — le cadre du panneau suffit, ni sidebar ni barre de navigation."""
body = _js_func(_library_js(), "openPeek(item) {")
assert "frame.src = '/pages/' + item.id" in body
assert "embed=1" not in _strip_js_comments(body)
assert "frame.src = '/pages/' + item.id + '?embed=1'" in body
def test_library_peek_body_has_no_inset_padding():
"""Le corps du peek de Library est nu (padding 0, overflow hidden) :
l'iframe doit remplir le panneau comme dans local-workspace, sans double
scrollbar ni bande grise autour du document."""
from pathlib import Path
html = Path("app/templates/library.html").read_text(encoding="utf-8")
rule = html.split(".peek-body{")[1].split("}")[0]
assert "padding:0" in rule
assert "overflow-y:auto" not in rule
def test_every_side_peek_loads_the_embed_editor_only():
"""Library, My Tasks, bases et workspace local : le peek ne montre QUE le
document éditable (`?embed=1`), jamais l'habillage pleine page."""
from pathlib import Path
for name in ("library.js", "my_tasks.js", "database_table.js",
"local_workspace.js"):
src = _strip_js_comments((Path("static/js") / name).read_text(encoding="utf-8"))
assert "?embed=1" in src, name
def test_library_peek_reloads_when_switching_documents():
@@ -1419,16 +1441,6 @@ def test_close_peek_unloads_the_editor_iframe():
assert "frame.onload = null" in body
def test_every_side_peek_loads_the_plain_page_url():
"""Library, My Tasks, bases et workspace local : même éditeur partout."""
from pathlib import Path
for name in ("library.js", "my_tasks.js", "database_table.js",
"local_workspace.js"):
src = (Path("static/js") / name).read_text(encoding="utf-8")
assert "?embed=1" not in _strip_js_comments(src), name
def test_calendar_offers_five_views():
"""Mois / Année / Semaine / Jour / Planning."""
js = _my_tasks_js()
@@ -1451,6 +1463,56 @@ def test_calendar_events_have_a_side_peek_icon():
assert "data-open-title=" in body
def test_open_buttons_resolve_with_closest_not_target():
"""Le gestionnaire de clic lit `data-open-row` via `closest` : les boutons
du calendrier et du Kanban ne contiennent que leur SVG — un clic sur
l'icone (e.target = <svg>, sans dataset) doit ouvrir le peek aussi."""
js = _my_tasks_js()
i = js.index("app.addEventListener('click'")
block = js[i:i + 900]
assert "closest('[data-open-row]')" in block
def test_kanban_cards_have_the_open_button():
"""Les tuiles du Kanban portent le même bouton « Open » que les lignes
du tableau (parité avec la page d'origine : même peek latéral)."""
body = _js_func(_my_tasks_js(), "function viewBoard(")
assert "openButton(t)" in body
# Le bouton est révélé au survol de la tuile (overlay, la carte est une
# colonne flex — pas de place en flux).
css = _my_tasks_css()
assert ".mt-card:hover .mt-hover-only" in css
def test_base_page_collection_renders_the_table_when_embedded():
"""`/pages/{id}?embed=1` sur une page de base garde le TABLEAU (template
collection) — le peek d'une base ouvre le tableau sans sidebar ni barre,
pas l'éditeur de blocs vide."""
from pathlib import Path
src = Path("app/routers/dashboard/pages_html.py").read_text(encoding="utf-8")
i = src.index('content_format") == "collection"')
branch = src[i:i + 220]
assert "not embed" not in branch.split("template")[0]
def test_embed_editor_can_render_inline_databases():
"""Le peek (`page_editor_embed.html`) doit charger database_table.js :
sans lui, une base embarquée restait bloquée à « Loading database… » et
l'icône Open de fin de ligne ne faisait rien. Le panneau #db-side-peek
est créé à la demande par database_table.js (markup unique)."""
from pathlib import Path
html = Path("app/templates/page_editor_embed.html").read_text(encoding="utf-8")
assert "_database_table_scripts.html" in html
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
assert "ensureDbPeek" in js
# Pas de second markup statique du panneau (source unique).
tbl = Path("app/templates/_database_table.html").read_text(encoding="utf-8")
assert 'id="db-side-peek"' not in tbl
def test_open_button_carries_its_title_as_attribute():
"""Le titre est lu depuis `dataset.openTitle` : disponible aussi dans le
calendrier et la vue Jour, où il n'y a pas de ligne `.mt-row`."""
@@ -1520,4 +1582,6 @@ def test_db_side_peek_reports_resolution_failure_in_the_panel():
assert "srcdoc" in block, "erreur rendue dans le panneau"
assert "Impossible d’ouvrir le contenu" in block
assert block.index("srcdoc") < block.index("panel.style.display = 'flex'")
# Le panneau (révélé par `classList.add('open')` depuis la réécriture
# vanilla JS de v7.49.0) doit s'afficher APRÈS l'injection du message.
assert block.index("srcdoc") < block.index("classList.add('open')")
+127
View File
@@ -0,0 +1,127 @@
"""v7.51.0 — Menu + de l'assistant : sections, parcours fichiers/répertoires,
jeton ``folder:<id>`` résolu par ContextBuilder."""
from pathlib import Path
from app.db import get_conn
from app.services.context_builder import ContextBuilder
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
def _uid() -> int:
with get_conn() as conn:
row = conn.execute("SELECT id FROM users LIMIT 1").fetchone()
return row["id"] if row else 1
def _folder_with_children(n_children: int = 2, body: str = "Contenu enfant.") -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
"VALUES ('ws', 'Dossier Specs', '', 'blocks', 'Workspace')"
)
fid = cur.lastrowid
for i in range(n_children):
conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, "
"parent_section, parent_id) VALUES ('ws', ?, ?, 'markdown', 'Private', ?)",
(f"Spec {i}", body * 10 if i else body, fid),
)
conn.commit()
return fid
def test_folder_mention_resolves_children(client):
"""folder:<id> = titre du dossier + contenu de ses documents directs."""
fid = _folder_with_children()
ctx = ContextBuilder(_uid()).build(mentions=[f"folder:{fid}"])
assert "Dossier Specs" in ctx
assert "Contenu enfant." in ctx
def test_folder_mention_truncates_on_budget(client):
"""Le contenu d'un dossier est borné (~12k) : un gros dossier n'explose pas le prompt."""
fid = _folder_with_children(n_children=12, body="X" * 800)
ctx = ContextBuilder(_uid()).build(mentions=[f"folder:{fid}"])
assert "tronqué" in ctx
assert len(ctx) < 20000
def test_folder_mention_unknown_id(client):
ctx = ContextBuilder(_uid()).build(mentions=["folder:999999"])
assert "introuvable" in ctx
def test_browse_contract_nav_menu(client):
"""Contrat que le parcours du menu + consomme : dossier = icon 'folder',
un niveau par appel via parent_id."""
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('BrowseWS', 1)")
ws_id = cur.lastrowid
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id) VALUES ('BrowseWS', ?, 'Dossier A', '', 'blocks', "
"'Workspace', NULL)",
(ws_id,),
)
folder_id = cur.lastrowid
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id) VALUES ('BrowseWS', ?, 'Note enfant', '', 'markdown', "
"'Private', ?)",
(ws_id, folder_id),
)
conn.commit()
root = client.get(f"/api/nav/menu?workspace_id={ws_id}").json()["items"]
names = {i["name"]: i for i in root}
assert names["Dossier A"]["icon"] == "folder"
kids = client.get(f"/api/nav/menu?workspace_id={ws_id}&parent_id={folder_id}").json()["items"]
kid = {i["name"]: i for i in kids}["Note enfant"]
assert kid["icon"] != "folder"
assert kid["url"] == f"/pages/{kid['id']}"
def test_agent_panel_renders_plus_menu(client):
"""La page rend bien le menu + (markup + bouton)."""
resp = client.get("/accounts")
assert resp.status_code == 200
html = resp.text
assert "fd-ap-plus-menu" in html
assert "fd-ap-add" in html
assert "Menu d'ajout au contexte" in html
def test_plus_menu_sections_and_disabled_states():
"""Les 6 sections du menu + existent ; seules les sections déjà livrées sont
actives (le menu ne promet rien que le code ne fait)."""
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
assert f"key:{key}" in root, key
assert "action:'files'" in root and "action:'skills'" in root
# phases 1-5 livrées → il reste 1 section « bientôt » (plugins)
assert root.count("disabled:true") == 1
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
assert "action:'search'" in files and "action:'browse'" in files
assert "folder:" in src and "document:" in src
def test_folder_chip_token_reaches_run_payload():
"""Le jeton folder:<id> épinglé part dans payload.mentions (contrat front→agent)."""
src = JS_PATH.read_text(encoding="utf-8")
# composerMentions() pioche dans contextChips[].token : le parcours doit
# produire des items avec token folder:/document:.
assert "token: 'folder:' + cur.id" in src
assert "token: (isFolder ? 'folder:' : 'document:') + x.id" in src
# ... et le backend sait les résoudre.
cb = (ROOT / "app" / "services" / "context_builder.py").read_text(
encoding="utf-8"
)
assert 'm.startswith("folder:")' in cb
assert "def _single_folder" in cb
+114
View File
@@ -0,0 +1,114 @@
"""v7.52.0 — Menu + : Compétences-skills (« Gérer » + « Parcourir »).
Couvre le nouveau ``PATCH /api/agent/skills/{id}``, l'import sans doublon et le
câblage du sous-menu (sections, alias affichés, formulaire)."""
from pathlib import Path
from conftest import anon_csrf
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
NEW_SKILL = {
"name": "synthese-reunion",
"description": "Synthèse de réunion",
"prompt_template": "Résume cette réunion en décisions et actions.",
}
def _create(client, **overrides):
body = {**NEW_SKILL, **overrides}
r = client.post("/api/agent/skills", json=body)
assert r.status_code == 200, r.text
return r.json()["id"]
def test_skill_patch_updates_fields(client):
sid = _create(client)
r = client.patch(f"/api/agent/skills/{sid}", json={
"name": "synthese-v2", "description": "Version 2",
"prompt_template": "Résume en 5 points.",
})
assert r.status_code == 200, r.text
assert r.json()["status"] == "updated"
skills = {s["id"]: s for s in client.get("/api/agent/skills").json()["skills"]}
assert skills[sid]["name"] == "synthese-v2"
assert skills[sid]["description"] == "Version 2"
assert skills[sid]["prompt_template"] == "Résume en 5 points."
def test_skill_patch_partial_and_validation(client):
sid = _create(client)
# mise à jour partielle : un seul champ ne touche pas les autres
assert client.patch(f"/api/agent/skills/{sid}", json={"description": "Seul champ"}).status_code == 200
skills = {s["id"]: s for s in client.get("/api/agent/skills").json()["skills"]}
assert skills[sid]["description"] == "Seul champ"
assert skills[sid]["prompt_template"] == NEW_SKILL["prompt_template"]
assert client.patch(f"/api/agent/skills/{sid}", json={}).status_code == 400
assert client.patch(f"/api/agent/skills/{sid}", json={"name": " "}).status_code == 400
assert client.patch("/api/agent/skills/999999", json={"name": "ghost"}).status_code == 404
def test_skill_patch_requires_session(client):
sid = _create(client)
anon_csrf(client) # CSRF valide mais plus de session → on veut le 401 de la route
assert client.patch(f"/api/agent/skills/{sid}", json={"name": "pirate"}).status_code == 401
def test_import_skill_has_no_duplicate(client):
payload = {"name": "skill-importe", "prompt_template": "Fais X.", "description": "import"}
first = client.post("/api/agent/skills/import", json={"payload": payload})
assert first.status_code == 200 and first.json()["status"] == "imported"
# même payload, sans overwrite → 409 (pas de doublon silencieux)
again = client.post("/api/agent/skills/import", json={"payload": payload})
assert again.status_code == 409
# avec overwrite → mise à jour de la même ligne
over = client.post("/api/agent/skills/import", json={"payload": payload, "overwrite": True})
assert over.status_code == 200 and over.json()["status"] == "updated"
names = [s["name"] for s in client.get("/api/agent/skills").json()["skills"]]
assert names.count("skill-importe") == 1
def test_export_then_roundtrip_import(client):
sid = _create(client)
doc = client.get(f"/api/agent/skills/{sid}/export").json()
client.delete(f"/api/agent/skills/{sid}")
back = client.post("/api/agent/skills/import", json={"payload": doc, "overwrite": True})
assert back.status_code == 200, back.text
assert back.json()["name"] == NEW_SKILL["name"]
def test_skills_menu_sections_enabled(client):
"""Sous-menu Compétences actif : alias affichés, Gérer + Parcourir, CRUD branché."""
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'skills'" in root
assert "bientôt (phase 2)" not in root
alias = src.split("var FD_SKILL_ALIAS = ", 1)[1].split(";", 1)[0]
assert "research" in alias and "Deep research" in alias
assert "create-new-skill" in alias and "Skill-creator" in alias
for action in ("'skills-manage'", "'skills-gallery'", "'skill-new'",
"'skill-edit'", "'skill-install'", "'skill-import'"):
assert action in src, action
for method in ("saveSkill()", "deleteSkill()", "exportSkill()", "importSkillFile"):
assert method in src, method
# PATCH utilisé pour l'édition (pas de POST qui recréerait la ligne)
assert "method: f.id ? 'PATCH' : 'POST'" in src
def test_skills_menu_markup_rendered(client):
html = PANEL_HTML.read_text(encoding="utf-8")
assert "fd-plus-form" in html
assert 'x-ref="skillImport"' in html
assert "Gérer les compétences" in JS_PATH.read_text(encoding="utf-8")
resp = client.get("/accounts")
assert resp.status_code == 200
assert "fd-plus-form" in resp.text
+73
View File
@@ -0,0 +1,73 @@
"""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
# phases 1-5 livrées → 1 section « bientôt » restante (plugins)
assert root.count("disabled:true") == 1
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
+138
View File
@@ -0,0 +1,138 @@
"""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
# phases 1-5 livrées → seul Plugins reste désactivé
assert root.count("disabled:true") == 1
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
+170
View File
@@ -0,0 +1,170 @@
"""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"}
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"
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
# phases 1-5 livrées → seule la section Plugins reste « bientôt »
assert root.count("disabled:true") == 1
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