Compare commits

..
7 Commits
Author SHA1 Message Date
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
28 changed files with 1872 additions and 172 deletions
+192
View File
@@ -1,5 +1,197 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## 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 ## v7.49.2 (2026-10-05) — Pages de base : titre au-dessus, tableau à gauche
### Fixed ### Fixed
+165
View File
@@ -284,6 +284,171 @@ 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-3 ✅ 2026-10-06 · phases 4-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
- [ ] **Table `agent_memory`** (`workspace_id`, `kind` summary|fact, `content`,
`updated_at`) + colonne `conversations.memory_enabled`
- [ ] **Service `app/services/agent_memory.py`** — extraction en fin de run (résumé +
faits saillants ; LLM sans outils, repli déterministe hors-ligne)
- [ ] **Injection dans `ContextBuilder` si le toggle est ON** ; OFF = contexte strict
de la conversation courante
- [ ] **Toggle « Mémoire »** dans le menu + : état visible, persisté par conversation,
défaut `AGENT_MEMORY_DEFAULT`
- [ ] **Tests** — injection ON / absence OFF / persistance / budget de tokens borné
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L
- [ ] **Table `agent_connectors`** (`kind`, `name`, `config`, credentials chiffrés,
`enabled`, `status`)
- [ ] **`app/services/connectors/`** — interface commune `probe() / list_sources() / fetch()`
+ 3 adapters **natifs déjà présents** : Gitea, GitHub, Web (`web_search` / `fetch_url`, v7.46)
- [ ] **Menu « Connecteurs »** — « Parcourir les connecteurs » (catalogue + statut
connecté/déconnecté) et « Ajouter un connecteur personnalisé » (URL + clé + type
HTTP / MCP)
- [ ] **Tools agent** — un tool par connecteur exposé au LLM via `tool_registry`
- [ ] **Sécurité** — garde SSRF `_is_public_host` sur toute URL, credentials chiffrés,
session requise, CSRF sur les écritures
- [ ] **Tests** — CRUD, statut, vecteurs SSRF, schéma des tools
### 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 à 3 livrées le
2026-10-06 (v7.51.0, v7.52.0, v7.53.0)**, phases 4-8 à valider une par une avant
« go ». Chaque phase se clôt par tests verts, bump de version, CHANGELOG et
ROADMAP à jour.*
---
## 🔜 Prochaines versions ## 🔜 Prochaines versions
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18) ### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
+1 -1
View File
@@ -1 +1 @@
7.49.2 7.53.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # 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` > **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 ## Avancement Global
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.49.2", version="7.53.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+37
View File
@@ -498,6 +498,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
return {"id": cur.lastrowid, "name": name, "status": "created"} 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") @router.post("/skills/{skill_id}/apply")
def apply_skill(request: Request, skill_id: int): def apply_skill(request: Request, skill_id: int):
"""Create a conversation pre-loaded with a skill, ready to run.""" """Create a conversation pre-loaded with a skill, ready to run."""
+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} 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 ── # ── 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) }}" "{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
).render(), ).render(),
) )
# Select template: collection pages use database table view # Select template: collection pages use database table view (aussi en
if page.get("content_format") == "collection" and not embed: # 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") template = env.get_template("page_editor_collection.html")
else: else:
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html") template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
+28
View File
@@ -86,6 +86,34 @@ BUILTIN_TEMPLATES: dict[str, dict] = {
_b("bulleted_list"), _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."),
],
},
} }
+34 -1
View File
@@ -118,7 +118,7 @@ class ContextBuilder:
return "\n".join(lines) return "\n".join(lines)
def _mentions_context(self, mentions: list[str]) -> str: 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 Mentions bring the *actual content* of the referenced object into the
context so the LLM can summarise / rewrite / analyse it directly without context so the LLM can summarise / rewrite / analyse it directly without
@@ -135,6 +135,8 @@ class ContextBuilder:
elif m.startswith("page:"): elif m.startswith("page:"):
pid = m.split(":", 1)[1] pid = m.split(":", 1)[1]
lines.append(self._single_page(pid)) lines.append(self._single_page(pid))
elif m.startswith("folder:"):
lines.append(self._single_folder(m.split(":", 1)[1]))
elif m.startswith("repo:"): elif m.startswith("repo:"):
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)") lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
elif m == "ws": elif m == "ws":
@@ -209,6 +211,37 @@ class ContextBuilder:
return f"{head}:\n{body[:9000]}" return f"{head}:\n{body[:9000]}"
return head 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: def _single_collection(self, cid: str) -> str:
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone() row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
+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 class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
</div> </div>
<!-- Side Peek Panel for Database Pages (vanilla JS, piloté par database_table.js) --> <!-- Le panneau de peek (#db-side-peek) est créé à la demande par
<div id="db-side-peek" class="db-side-peek"> database_table.js (ensureDbPeek) : une seule source de markup pour
<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div> la page de base et les bases embarquées dans un document. -->
<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>
</div> </div>
+67 -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-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} .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 */ /* 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{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)} .fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
@@ -380,6 +400,52 @@ body.fd-ap-resizing *{cursor:col-resize!important}
</template> </template>
</template> </template>
</div> </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>
<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>
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak> <div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
<div class="fd-chip-row" x-show="mentionChips.length"> <div class="fd-chip-row" x-show="mentionChips.length">
@@ -406,7 +472,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" 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> @input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
<div class="fd-ap-toolbar"> <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> <span class="fd-ap-tip" x-text="composerHint"></span>
<div class="fd-ap-tools"> <div class="fd-ap-tools">
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle"> <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': case 'openTab':
window.open('/' + pageId, '_blank'); window.open('/' + pageId, '_blank');
break; break;
case 'openPeek': case 'openPeek': {
this.toast('Side peek not available in browser', 'info'); // 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; break;
}
} }
this.contextMenu.visible = false; 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{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:hover{background:var(--bg-hover);color:var(--text-primary);}
.peek-header button svg{width:15px;height:15px;} .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-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
.peek-loading{text-align:center;padding:40px;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 <!-- 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 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). d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
Charger `/pages/{id}` sans `?embed=1` : c'est exactement l'editeur Charger `/pages/{id}?embed=1` : le peek affiche uniquement le
plein ecran, le peek n'est plus une variante allegee. --> 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-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
<div class="peek-loading" id="lib-peek-loading">Chargement…</div> <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> <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 %} {% endblock %}
{% block scripts %} {% block scripts %}
{% include '_page_editor_scripts.html' %} {% 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 %} {% endblock %}
+208
View File
@@ -0,0 +1,208 @@
# V74 — Menu + de l'assistant : hub de contexte (design)
> **Statut** : design — **phases 1 à 3 livrées (v7.51.0 → v7.53.0, 2026-10-06)** ;
> phases 4-8 restent à livrer. 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).
+143 -42
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.49.2" "version": "7.53.0"
}, },
"paths": { "paths": {
"/auth/register": { "/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": { "/board/library": {
"get": { "get": {
"tags": [ "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": { "/api/agent/skills/{skill_id}/apply": {
"post": { "post": {
"tags": [ "tags": [
@@ -16099,46 +16240,6 @@
} }
} }
}, },
"/api/agent/skills/{skill_id}": {
"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/mentions": { "/api/agent/mentions": {
"get": { "get": {
"tags": [ "tags": [
@@ -29889,4 +29990,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 /* 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 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-cover-area,
.page-editor-wrapper.db-first .page-title-block, .page-editor-wrapper.db-first .page-title-block,
.page-editor-wrapper.db-first .blocks-container { .page-editor-wrapper.db-first .blocks-container {
max-width: 1100px; max-width: 100%;
margin: 0; margin: 0;
padding-left: var(--space-lg); padding-left: var(--space-lg);
padding-right: var(--space-lg);
} }
/* Une base embarquée ne doit jamais déborder de sa colonne de blocs : /* 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 /* Titre des pages de base dédiées (page_editor_collection.html) — même
liseré gauche que .database-table-container. */ liseré gauche que .database-table-container. */
.database-page-title { .database-page-title {
max-width: 1100px; max-width: 100%;
margin: 0; margin: 0;
padding: 28px var(--space-lg) 0; padding: 28px var(--space-lg) 0;
font-size: 30px; font-size: 30px;
@@ -3899,11 +3901,10 @@ button.breadcrumb-current { max-width: 260px; }
═══════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════ */
/* Base = viewport du contenu ; les vues tabulaires larges (bases de /* Base = viewport du contenu ; les vues tabulaires larges (bases de
données) s'alignent à gauche, au ras de la marge, à la même largeur données) prennent la LARGEUR DU DOCUMENT (les colonnes dictent le scroll
que Library (1100px). */ horizontal), alignées à gauche au ras de la marge. */
.database-table-container { .database-table-container {
width: 100%; width: 100%;
max-width: 1100px;
margin: 0; margin: 0;
/* var(--space-lg) = padding de .topbar : le bord gauche du tableau /* 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. */ 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:active { cursor: grabbing; opacity: .7; }
.mt-card-title { font-weight: 500; } .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-due,
.mt-card-src { font-size: 11px; color: var(--text-dim, #999); } .mt-card-src { font-size: 11px; color: var(--text-dim, #999); }
.mt-card-src { opacity: .8; } .mt-card-src { opacity: .8; }
+433 -4
View File
@@ -32,6 +32,30 @@
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack', {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].'} 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 / connecteur personnalisé — bientôt (phase 5)', disabled: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:'Mémoire persistante — bientôt (phase 4)', disabled:true}
];
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(){ function agentPanel(){
return { return {
@@ -45,6 +69,11 @@
contextChips: [], skills: [], gallery: [], contextChips: [], skills: [], gallery: [],
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false, mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null, 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,
cmdFocus: -1, slashDismiss: false, cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null, toastMsg: '', toastErr: false, _toastTimer: null,
_livePh: null, _livePh: null,
@@ -469,7 +498,8 @@
var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨', var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨',
label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id}; label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id};
this.contextChips.push(pin); 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._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.'); this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
}, },
@@ -1070,12 +1100,397 @@
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200); this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
}, },
// ── Mentions & contexte épinglé (@ / +) ── // ── Menu + (sections) & mentions (@ / recherche) ──
toggleAddPicker(){ toggleAddPicker(){
if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; } if(this.mentionOpen){
this.openMentionPicker('+'); 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 === '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 === 'canvases') return this._canvasItems();
if(s === 'canvas') return this._canvasActionItems();
return FD_PLUS_MENU;
},
// 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;
},
// ── 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 === '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;
}
// ── 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; }
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){ openMentionPicker(from){
this.plusOpen = false;
this.mentionOpen = true; this.mentionOpen = true;
this._mentionFrom = from || '+'; this._mentionFrom = from || '+';
this.mentionQuery = ''; this.mentionQuery = '';
@@ -1177,6 +1592,7 @@
onInput(){ onInput(){
this.resizeInput(); this.resizeInput();
this.slashDismiss = false; this.slashDismiss = false;
if(this.plusOpen) this.closePlus(); // tape = on écrit le message, le menu se referme
var text = this.composerText(); var text = this.composerText();
this.input = text; this.input = text;
var cmdNow = /^\s*\//.test(text); var cmdNow = /^\s*\//.test(text);
@@ -1249,16 +1665,29 @@
}catch { /* volontaire */ } }catch { /* volontaire */ }
} }
if(key === 'Escape'){ 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.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; } if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
return; // laisse fermer le panneau return; // laisse fermer le panneau
} }
if(key === 'ArrowDown' || key === 'ArrowUp'){ if(key === 'ArrowDown' || key === 'ArrowUp'){
var delta = key === 'ArrowDown' ? 1 : -1; 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.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; } if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
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) // Entrée / Tab avec un menu ouvert (skills / mentions)
if(this.mentionOpen){ if(this.mentionOpen){
var mSel = this._selectableMentionItems(); 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) ── // ── Side peek : panneau latéral des lignes de base (vanilla JS) ──
// Le panneau vit dans _database_table.html (page de base ET bases // Le panneau vit dans _database_table.html (page de base). Une base
// embarquées inline). Il doit être câblé SUR le document, jamais via // EMBARQUÉE dans un document n'a pas ce markup : on le crée à la demande
// container.__x : le conteneur n'a pas de x-data (les bindings Alpine // (comme mt-peek de My Tasks), sinon l'icône Open de fin de ligne était
// du panneau restaient inertes — clic sur une ligne = rien). // un no-op silencieux. Il doit être câblé SUR le document, jamais via
var peekEl = document.getElementById('db-side-peek'); // container.__x : le conteneur n'a pas de x-data.
// Sans panneau (collections embarquées inline dans l'éditeur), le clic ne var PEEK_HTML =
// doit pas lever de ReferenceError : repli discret, comme l'ancien code. '<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>' +
if (!peekEl) window.openPageInSidePeek = function() {}; '<div class="db-side-peek-header">' +
if (peekEl) { '<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 peekFrame = document.getElementById('db-side-peek-iframe');
var peekTitle = document.getElementById('db-side-peek-title'); var peekTitle = document.getElementById('db-side-peek-title');
var peekLoading = document.getElementById('db-side-peek-loading'); var peekLoading = document.getElementById('db-side-peek-loading');
function closeDbPeek() { peekEl.classList.add('open');
peekEl.classList.remove('open'); if (peekLoading) peekLoading.style.display = '';
document.getElementById('db-side-peek-iframe').src='about:blank'; if (peekFrame) peekFrame.src = 'about:blank';
if (peekTitle) { peekTitle.textContent = ''; } if (peekTitle) peekTitle.textContent = '';
} resolveRowContentPage(pageId).then(function(pid){
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) {
if (!peekEl.classList.contains('open')) return; if (!peekEl.classList.contains('open')) return;
if (peekEl.contains(e.target)) return; if (peekLoading) peekLoading.style.display = 'none';
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return; // `?embed=1` : uniquement le document edite, pas l'habillage
closeDbPeek(); // (sidebar + barre) — la pleine page reste via le bouton « full ».
}); if (peekFrame) { peekFrame.dataset.page = pid; peekFrame.src = '/pages/'+pid+'?embed=1'; }
function wireDbPeekResize() { })
if (window.fdWirePeekResize) { .catch(function(){
window.fdWirePeekResize(peekEl, peekEl.querySelector('.peek-resize-handle'), closeDbPeek); // L'échec est rendu DANS le panneau (srcdoc) : sous le build
} else if (document.readyState === 'loading') { // CSP d'Alpine les toasts ne s'affichent pas partout.
document.addEventListener('DOMContentLoaded', wireDbPeekResize); 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>';
} }
} peekEl.classList.add('open');
wireDbPeekResize(); 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){ function resolveRowContentPage(rowId){
return fetch('/db/pages/'+rowId+'/open/api') return fetch('/db/pages/'+rowId+'/open/api')
+3 -3
View File
@@ -979,9 +979,9 @@ _renderIcon(item) {
if (loading) loading.style.display = ''; if (loading) loading.style.display = '';
frame.onload = function () { if (loading) loading.style.display = 'none'; }; frame.onload = function () { if (loading) loading.style.display = 'none'; };
frame.src = 'about:blank'; frame.src = 'about:blank';
// `/pages/{id}` sans `?embed=1` : le peek affiche le MÊME éditeur que la // `?embed=1` : le peek affiche SEULEMENT le document en mode édition
// page pleine (sidebar, barre, actions compris), pas une variante. // (ni sidebar ni barre de navigation) — le cadre du panneau suffit.
frame.src = '/pages/' + item.id; frame.src = '/pages/' + item.id + '?embed=1';
}, },
closePeek() { closePeek() {
+5 -5
View File
@@ -395,12 +395,12 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
window.showToast('Failed to copy', 'error'); window.showToast('Failed to copy', 'error');
}); });
}; };
// Load the full-screen editor in the iframe (no `?embed=1`: the peek // Load ONLY the editable document in the iframe (`?embed=1`: no
// shows the same editor as the page itself). `about:blank` first, so // sidebar, no navbar). `about:blank` first, so opening another
// opening another document really reloads the frame instead of keeping // document really reloads the frame instead of keeping the previous
// the previous one when the URL happens to be identical. // one when the URL happens to be identical.
var iframe = document.getElementById('ws-preview-iframe'); 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 // Show panel
panel.style.display = 'flex'; panel.style.display = 'flex';
requestAnimationFrame(function() { requestAnimationFrame(function() {
+12 -6
View File
@@ -152,9 +152,10 @@ function flowDeckMyTasks() {
f.style.display = ''; f.style.display = '';
// `about:blank` d'abord : ouvrir une autre tâche doit REcharger // `about:blank` d'abord : ouvrir une autre tâche doit REcharger
// l'éditeur, sinon l'URL identique laisse le document précédent // 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 = 'about:blank';
f.src = '/pages/' + pid; f.src = '/pages/' + pid + '?embed=1';
}).catch(function (e) { }).catch(function (e) {
if (loading) { if (loading) {
loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message; 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 + '">' + h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' +
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' + '<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
'<span class="mt-card-title">' + esc(t.title) + '</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) + '">📅 ' + (t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' +
esc(t.due) + '</span>' : '') + esc(t.due) + '</span>' : '') +
'<span class="mt-card-src">' + esc(t.collection_icon) + ' ' + '<span class="mt-card-src">' + esc(t.collection_icon) + ' ' +
@@ -878,17 +880,21 @@ function flowDeckMyTasks() {
var t = e.target; var t = e.target;
if (t.id === 'mt-new-btn') { openNewModal(); return; } 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(); e.preventDefault();
// Le titre vient de l'attribut : il est disponible aussi bien dans // Le titre vient de l'attribut : il est disponible aussi bien dans
// le tableau que dans le calendrier ou la vue Jour. // le tableau que dans le calendrier ou la vue Jour.
var label = t.dataset.openTitle; var label = opener.dataset.openTitle;
if (!label) { if (!label) {
var row = t.closest('.mt-row'); var row = opener.closest('.mt-row');
var cell = row && row.querySelector('.mt-title-text'); var cell = row && row.querySelector('.mt-title-text');
label = cell ? cell.textContent : ''; label = cell ? cell.textContent : '';
} }
openRowContent(Number(t.dataset.openRow), label); openRowContent(Number(opener.dataset.openRow), label);
return; return;
} }
if (t.dataset && t.dataset.fixSource) { 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" assert at > html.index("</template>"), "l'iframe doit être hors du x-if"
def test_library_peek_loads_the_full_screen_editor(): def test_library_peek_loads_the_embed_editor_only():
"""Le peek charge `/pages/{id}` SANS `?embed=1` : exactement l'éditeur """Le peek charge `/pages/{id}?embed=1` : le document en mode édition
de la page entière (le mode embed masquait sidebar, barre et actions).""" SEUL — le cadre du panneau suffit, ni sidebar ni barre de navigation."""
body = _js_func(_library_js(), "openPeek(item) {") body = _js_func(_library_js(), "openPeek(item) {")
assert "frame.src = '/pages/' + item.id" in body assert "frame.src = '/pages/' + item.id + '?embed=1'" in body
assert "embed=1" not in _strip_js_comments(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(): 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 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(): def test_calendar_offers_five_views():
"""Mois / Année / Semaine / Jour / Planning.""" """Mois / Année / Semaine / Jour / Planning."""
js = _my_tasks_js() js = _my_tasks_js()
@@ -1451,6 +1463,56 @@ def test_calendar_events_have_a_side_peek_icon():
assert "data-open-title=" in body 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(): def test_open_button_carries_its_title_as_attribute():
"""Le titre est lu depuis `dataset.openTitle` : disponible aussi dans le """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`.""" 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 "srcdoc" in block, "erreur rendue dans le panneau"
assert "Impossible d’ouvrir le contenu" in block 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-3 livrées → il reste 3 sections « bientôt » (connecteurs, plugins, mémoire)
assert root.count("disabled:true") == 3
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-3 livrées → 3 sections « bientôt » restantes (connecteurs, plugins, mémoire)
assert root.count("disabled:true") == 3
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