Compare commits

...
18 Commits
Author SHA1 Message Date
bruno a67bd252aa fix: aide (bouton ✕), Insert below qui perdait le format, listes numérotées invisibles (v7.59.2)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 18m25s
FlowDeck CI / docker (push) Skipped
- aide : bouton ✕ en haut à droite du panneau (classe partagée
  settings-close, 44 px mobile) + helpInit.close() (retour en arrière,
  sinon /workspaces) ; .settings-panel en position:relative pour ancrer
  hamburger + ✕ au panneau
- éditeur « Insert below » (/Write with AI) : insère le markdown source
  conservé à la génération (AIC._md) au lieu du texte extrait du HTML rendu
  (_resultText() reste seulement en repli) → titres/listes/gras conservés
- listes numérotées : aucun numéro n'était affiché (CSS = retrait seul) ;
  le rang des blocs numbered_list contigus est calculé au rendu (data-num)
  et rendu via content: attr(data-num) — /numbered list et l'insertion
  agent affichent 1, 2, 3
- md2b : cases à cocher - [ ] / - [x] testées avant la branche des puces
  (aligné sur paste2b) + séparateur 1) accepté (CommonMark)
- menu slash : requête tapée dans le bloc (/numbered list) répercutée dans
  le filtre du menu quand le focus n'a pas rejoint l'entrée
- tests : tests/test_v7592_ui_fixes.py (5 tests, dont 1 comportemental node
  qui exécute le vrai md2b) ; ruff I001 préexistant sur test_agent.py
- livraison : VERSION + app/main = 7.59.2, OpenAPI 525 chemins, CHANGELOG
2026-10-07 20:29:02 -04:00
bruno 39d34ac866 fix(llm): Mistral 403 tier_not_allowed — défauts tous-plans + sonde chat + migration 36 (v7.59.1)
FlowDeck CI / lint (push) Failing after 2m8s
FlowDeck CI / test (push) Failing after 20m4s
FlowDeck CI / docker (push) Skipped
La clé fonctionne: /v1/models 200 + chat 200 (vérifié live). L'échec venait
du modèle: mistral-large-latest (défaut PROVIDERS + tête de liste) n'est pas
servi par les plans d'abonnement basiques → 403 code 1910 « tier_not_allowed »,
et le Test connection sélectionnait systématiquement ce premier candidat.

- PROVIDERS mistral default → mistral-small-latest (tous plans)
- PROVIDER_MODELS réordonnée: tous-plans d'abord (ordre du test de connexion)
- mistral ∈ _CHAT_VALIDATED_PROVIDERS: le fetch modèles ne garde que les
  modèles réellement servis (46 listés → 25 utilisables avec la clé du compte)
- migration 36: default_model bloqué (large/pixtral-large) reset en base

Test live déployé: {ok:true, model:mistral-small-latest, reply:PONG,
verified:true} · tests/test_agent.py 53/53 · nouveau test d'ancrage
test_mistral_defaults_are_tier_safe
2026-10-07 16:25:04 -04:00
bruno efa3d57e93 feat(mobile): side-nav settings + scroll drawer + aide refondue settings-style (v7.59.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 23m33s
FlowDeck CI / docker (push) Skipped
- Sidebar mobile: display:block du breakpoint ≤768px cassait flex:1 de
  .sidebar-scroll → molette/touch incapables de scroller le drawer. Flex
  restauré. Gate: wheel scroll scrollTop > 0.
- Settings mobile: nav horizontale remplacée par une side-navigation gauche
  drawer (hamburger .settings-menu-btn + backdrop, fermeture auto au choix).
  Squelette panneau extrait vers static/css/settings.css (partagé).
- /help refaite au complet: template help.html (13 sections couvrant toute
  l'app — éditeur, databases/vues, tâches, workspaces, collab, agent IA,
  API v2, PWA, SSO, raccourcis, tips) avec le même panneau 2 colonnes +
  drawer mobile; route réduite à un render; help.js (Alpine.data helpInit).

E2E mobile_regression 6/6 + smoke 2/2 · pytest app+pwa 230/230
2026-10-07 15:36:05 -04:00
bruno 992200a90a fix(mobile): hamburger ouvrait sidebarCollapsed au lieu du drawer + layout settings responsive (v7.58.1)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 21m14s
FlowDeck CI / docker (push) Skipped
Le handler du hamburger était une expression Alpine avec un effet de bord
dans un ternaire (sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, ...)).
Évaluée sans eval dans le build CSP d'Alpine, elle ne produisait aucun effet
visible sur mobile: le bouton ne faisait rien (prouvé via Alpine.evaluate@390px,
open=false). Remplacée par la méthode fdHamburgerClick() (matchMedia 768px:
drawer mobile, sinon toggle desktop).

Settings: panel 1050px + nav verticale 200px inutilisable sur 390px →
@media 768px: plein écran (dvh), nav en onglets horizontaux scrollables,
rows en colonne, prov-grid 1 colonne, close 44px.

+ gate e2e mobile_regression.spec.js (drawer open/close + viewport settings)
2026-10-07 14:27:33 -04:00
bruno 21b96afa12 merge: origin/develop → main (contenu déjà intégré, arbre identique)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 18m47s
FlowDeck CI / docker (push) Skipped
Vérification : merge-tree propre, diff de l'arbre fusionné vs main vide —
les 4 fichiers de 0f86294 sont déjà en main. Merge d'alignement de branche.
2026-10-07 12:08:32 -04:00
bruno 16f73fe39e feat: Add plugins — catalogue on/off à effet réel (v7.58.0, phase 8/8)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h7m52s
- app/services/plugins.py + migration 35 : table plugins (slug, name,
  description, enabled) pré-remplie avec 3 modules câblés — web-tools,
  web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
  (aucun router touché) → toutes les routes /workspace/automations* refusées +
  garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
  via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
  « Extensions » / « Automations » en {% if %} (absentes du DOM), sections
  conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
  liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
  (slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
  /api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
  câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
  ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
2026-10-07 10:19:25 -04:00
bruno f8acb906e5 feat: connecteurs Discord/Telegram/MCP + outils MCP dynamiques au registre (v7.57.0)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 17m51s
FlowDeck CI / docker (push) Skipped
- presets Discord / Telegram : colonnes kind + auth (bearer/bot/none) sur
  agent_connectors (migration 34) ; Discord envoie le jeton préfixé dans
  l'en-tête d'autorisation, Telegram le place dans l'URL via le substitut
  {secret} remplacé à l'appel — jamais stocké en clair
- Teams : scope ChannelMessage.Read.All ajouté aux Graph scopes M365
- app/services/mcp_client.py : handshake initialize → notifications/initialized
  → tools/list → tools/call (JSON-RPC 2.0, réponse JSON ou premier data: d'un
  text/event-stream), garde SSRF, outils cachés en base (tools_json) —
  le bouton « Tester » rejoue le handshake
- ToolRegistry._all() merge le cache MCP à chaque run : outils exposés au LLM
  sous mcp_<serveur>_<outil> avec leur inputSchema, dispatch tools/call ;
  serveur désactivé → outil absent du schéma, échec réseau → ToolResult(error)
- menu + : champ Type dans le formulaire connecteur (preset URL + auth),
  badge « · N outil(s) » sur la fiche d'un serveur MCP
- tests : tests/test_v757_mcp_discord.py (12 tests, 0 appel réseau réel) ;
  suite complète 1331 verts, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.57.0, OpenAPI 523 chemins, CHANGELOG,
  ROADMAP phase 7 cochée, avenant phase 7 dans docs/V74_Agent_Plus_Menu.md
2026-10-07 09:09:19 -04:00
bruno 1d7750bda0 feat: connecteurs Google + Microsoft 365 (OAuth2 PKCE) — phase 6/8 (v7.56.0)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 16m4s
FlowDeck CI / docker (push) Skipped
- app/services/oauth_connectors.py : flow OAuth2 complet PKCE (S256) pour
  2 fournisseurs décrits par 1 dict — Google (Drive/Gmail/Calendar en lecture
  seule) et Microsoft 365 (Graph Files.Read / Mail.Read / Calendars.Read) ;
  begin() = URL d'autorisation + state + code_verifier, complete() = échange du
  code, access_token() = refresh automatique (60 s de marge, refresh_token
  conservé si absent de la réponse), api_get() = path absolu refusé + validation
  SSRF + borne 20 000 car.
- Tokens chiffrés Fernet en réutilisant calendar_sync._encrypt_tokens (zéro
  dépendance) dans la table connector_tokens (migration 33, PK (kind, user_id)).
- 4 routes /api/agent/connectors/oauth/{kind}/… : status, authorize (cookies
  d'état HttpOnly 10 min, retour same-origin validé), callback (GET safe, state
  comparé en temps constant, tokens stockés puis cookies purgés, redirection
  ?oauth=connected / ?oauth_error=), disconnect. OpenAPI 523 chemins.
- Config + .env.example : GOOGLE_CLIENT_ID/SECRET, MS_CLIENT_ID/SECRET (vidés =
  « non configuré »), redirect URI dérivé d'APP_BASE_URL.
- Catalogue : google/ms365 en natifs avec badge connecté/non connecté ;
  connector_fetch et Tester passent par l'API du fournisseur avec le token de
  l'utilisateur (user_id transmis par l'outil LLM).
- Menu + : « Se connecter » / « Déconnecter » sur la fiche, toast au retour du
  flux (URL nettoyée par history.replaceState). État dans la fiche du menu
  plutôt qu'une page dédiée.
- Tests : tests/test_v756_oauth_connectors.py (13), 0 appel réseau réel
  (_post_form / _api_get monkeypatchés) — state forgé refusé sans échange,
  tokens chiffrés en base, refresh, URL absolue refusée, 401/404, câblage menu ;
  test_v755 adapté (5 natifs). Suite complète 1319 verts (-n auto), ruff 0,
  eslint 0 erreur (19 warnings préexistants hors fichiers touchés).
2026-10-07 08:29:22 -04:00
bruno 18c72d77fe feat: menu + — Connecteurs (socle) — phase 5/8 (v7.55.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 15m16s
FlowDeck CI / docker (push) Skipped
- Table agent_connectors (migration 32) : colonnes plates name, url,
  secret_encrypted, enabled, status, detail — config_json remis (YAGNI, les
  scopes OAuth des phases 6-7 ajouteront le leur).
- app/services/connectors.py : 1 fichier au lieu du package connectors/ —
  3 natifs (gitea, github, web) servis à la volée avec statut sans réseau,
  CRUD des personnels (URL validée par _validate_url = garde SSRF, clé chiffrée
  Fernet et jamais renvoyée — seul has_secret), probe() qui persiste
  status/detail, connector_fetch() borné à 20 000 car.
- API /api/agent/connectors : GET, POST (400 URL privée), PATCH, DELETE,
  POST /connectors/probe — 401 sans session, CSRF global. OpenAPI 519 chemins.
- Outil LLM connector_fetch (26e outil) : un seul outil qui dispatche vers
  Gitea/GitHub/web/personnalisé (id ou nom ou kind + path + query) au lieu d'un
  outil par connecteur ; désactivé/inconnu = erreur outil, jamais de run cassé.
- Menu + : section « Connecteurs » (catalogue avec badge ✓/✗/⚠/?), fiche par
  connecteur (Tester, Activer/Désactiver, Supprimer — masqués pour les natifs),
  formulaire « Ajouter un connecteur personnalisé » (clé en type=password).
- Tests : tests/test_v755_connectors.py (13) — 3 natifs, roundtrip Fernet de la
  clé, 5 URLs refusées (localhost, 127.0.0.1, metadata cloud, ftp:, file:),
  toggle persisté, 401, probe OK/erreur, outil complet, câblage menu ;
  test_v751/v753/v754 adaptés (1 seule section « bientôt » = plugins).
  Suite complète 1306 verts (-n auto), ruff 0, eslint 0 problème.
2026-10-06 23:26:19 -04:00
bruno 64c85caffc feat: menu + — Mémoire de l'agent — phase 4/8 (v7.54.0)
FlowDeck CI / lint (push) Successful in 2m13s
FlowDeck CI / test (push) Failing after 3h10m19s
FlowDeck CI / docker (push) Skipped
- Le moteur n'envoie jamais l'historique (system + objectif courant seulement)
  : chaque run repartait de zéro. Nouveau service app/services/agent_memory.py —
  une ligne résumé par conversation (table agent_memory, migration 31),
  écrite en fin de run et ré-injectée au contexte du run suivant.
- context_for() : bloc « ## Mémoire de la conversation », budget 4000 car.
  (troncature gauche + marqueur). remember() : 1 note/échange (180 → 700 car.),
  20 notes max, upsert, erreurs journalisées — un run ne peut pas échouer à
  cause de la mémoire. Toggle OFF = ni lecture ni écriture.
- Toggle « Mémoire » dans le menu + : entrée racine à l'état réel (🧠/💭 +
  libellé), PATCH /api/agent/conversations/{id} avec memory_enabled ajouté au
  whitelist, état lu à l'ouverture et à la création, défaut AGENT_MEMORY_DEFAULT
  (settings.agent_memory_default) + memory_enabled dans la réponse de création.
- Décision : mémoire d'espace (lignes workspace_id NULL) remise — YAGNI, rien
  ne l'écrivait ; noté ponytail: dans le service avec le chemin de montée.
- Tests : tests/test_v754_memory.py (8) dont 2 runs réels du moteur (FakeLLM
  capture le prompt : mémoire présente ON / absente OFF) ; test_v751 et
  test_v753 adaptés (2 sections « bientôt » restantes). Suite complète 1293
  verts (-n auto, 2e run ; 1 échec intermittent sur test_v56_import URL au
  1er run, vert isolé + vert au run suivant = flake préexistant), ruff 0,
  eslint 0 problème. OpenAPI 516 chemins / 7.54.0.
2026-10-06 22:53:15 -04:00
bruno a68e8848ea feat: menu + — Design System – Canevas — phase 3/8 (v7.53.0)
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / test (push) Failing after 14m56s
FlowDeck CI / docker (push) Skipped
- Section « Design System – Canevas » : liste des canevas (intégrés +
  personnels, GET /board/api/page-templates) puis page de détail avec
  « Créer une page à partir du canevas », « Insérer dans le document ouvert »
  (désactivé sans document ouvert) et « Enregistrer le document ouvert comme
  canevas ».
- GET /board/api/page-templates/{id}/blocks (nouvelle route) : blocs d'un
  canevas — intégré via template_id=0&key=, sinon id ; 404 clé/id inconnu.
  Insertion côté front via ensureBlockIds() global + concat dans E.blocks
  + autoSave/render.
- Canevas « Design System » ajouté aux builtins (callout tokens, TOC, toggles
  composants, grille, checklist revue UI, citation).
- Fix : le front du panneau appelait /api/page-templates (404 silencieux) —
  le router est sous prefix=/board → /board/api/page-templates.
- Piège : pour /use la clé builtin se passe DANS LE BODY (body.key), pas en
  query string — test garde-fou ajouté.
- Tests : tests/test_v753_canvases.py (5) + test_v751 adapté. Suite complète
  1285 verts (-n auto), ruff 0, eslint 0 problème. OpenAPI 516 chemins.
2026-10-06 21:59:34 -04:00
bruno 18a2d45d46 feat: menu + — Compétences-skills « Gérer » + « Parcourir » — phase 2/8 (v7.52.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 13m54s
FlowDeck CI / docker (push) Skipped
- Sous-menu « Compétences-skills » : 11 skills builtin + skills enregistrés,
  libellés affichés « Deep research » (/research) et « Skill-creator »
  (/create-new-skill) via FD_SKILL_ALIAS (slugs intacts) ; le clic épingle le
  skill au contexte, même chemin que la palette /.
- « Gérer les compétences » : section du menu + formulaire intégré (nom,
  description, prompt) — création, édition, suppression (chip épinglé nettoyé),
  export JSON téléchargé, import fichier avec overwrite (pas de doublon).
- « Parcourir les compétences » : section galerie filtrable (nom/description/
  slug), installation en 1 clic (installGallerySkill réutilisé).
- PATCH /api/agent/skills/{skill_id} créé : édition partielle, whitelist de
  colonnes, 400/401/404/409. OpenAPI régénéré : 515 chemins, version 7.52.0.
- En-tête ← + titre de section dans le menu (navigation à 3 niveaux).
- Fix : épingler un skill enregistré n'efface plus le message déjà tapé
  (_pinDbSkill ne vide le composer que s'il est vide).
- Tests : tests/test_v752_skills_menu.py (7) + test_v751 adapté. Suite
  complète 1280 verts (-n auto), ruff 0, eslint 0 problème.
2026-10-06 21:39:24 -04:00
bruno f271ac9b7a feat: menu + de l'assistant en hub de contexte — phase 1/8 (v7.51.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 3h10m48s
FlowDeck CI / docker (push) Skipped
- Bouton + : menu à sections (fichiers/répertoires, compétences-skills,
  connecteurs, Design System – Canevas, Add plugins, Mémoire on/off).
  Sections pas encore livrées affichées « bientôt (phase N) » mais désactivées,
  navigation clavier ↑/↓/Entrée/Échap, focus visible, la frappe referme le menu.
- Parcours « Parcourir… » : un niveau par appel via GET /api/nav/menu
  (contrat : dossier = icon 'folder'), fil d'Ariane cliquable, épingle de dossier
  via la ligne « 📌 Épingler le dossier ».
- Jeton folder:<id> résolu par ContextBuilder._single_folder() : titre du dossier
  + documents directs, budget ~12k caractères (marqueur « tronqué »), enfants
  directs seulement.
- « Rechercher… » conserve l'ancien sélecteur de mentions (@ inline + recherche
  par nom) : régression zéro sur le chemin existant.
- Tests : tests/test_v751_plus_menu.py (7 tests). Suite complète 1273 verts
  (-n auto), ruff check app tests propre, eslint static/js 0 erreur.
- Docs : CHANGELOG, ROADMAP (phase 1 cochée), docs/V74_Agent_Plus_Menu.md statut.
2026-10-06 20:01:47 -04:00
bruno f006880394 docs: plan par phases du menu + de l'assistant + design V74
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / test (push) Failing after 13m39s
FlowDeck CI / docker (push) Skipped
- ROADMAP : section « v7.51.0 → v7.58.0 — Menu + de l'assistant »
  (8 phases : menu hub/fichiers-répertoires, compétences, canevas,
  mémoire, connecteurs socle, Google+M365, Discord/Telegram/MCP, plugins),
  état des lieux lu dans le code, aucun case cochée.
- docs/V74_Agent_Plus_Menu.md : maquette, découpage front/back, modèle de
  données, sécurité (SSRF, secrets Fernet, CSRF), décisions D1-D7,
  pièges repo, critères d'acceptation par phase.
2026-10-06 19:35:22 -04:00
bruno 81746d9440 fix: peek de Library nu comme local-workspace - iframe pleine (v7.50.2)
FlowDeck CI / test (push) Failing after 13m34s
FlowDeck CI / lint (push) Successful in 2m32s
FlowDeck CI / docker (push) Skipped
.peek-body gardait padding:16px 20px + overflow-y:auto (heritage de l'ancien
peek a rendu HTML) : l'iframe etait amochee (519x816 mesures contre 559x848
en local-workspace) avec bande grise autour du document + double scrollbar.
Corps nu (padding:0, overflow:hidden) : l'editeur remplit le panneau,
exactement comme le peek de /local-workspace.

Mesure Playwright (cookie de session forge, les 14 boutons Open de Library) :
embed OK partout, bases hydratees, aucun jeton « Loading database... ».
Test: test_library_peek_body_has_no_inset_padding. Suite 1266/0 (-n auto)
+ ruff 0. Bump VERSION/main.py 7.50.2, CHANGELOG, WORKLOAD, OpenAPI.
2026-10-06 15:53:04 -04:00
bruno f53205bc45 fix: peek des bases hydrate FlowDeckDB + Open de ligne, tableau pleine largeur (v7.50.1)
FlowDeck CI / test (push) Failing after 14m37s
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / docker (push) Skipped
- page_editor_embed.html incluait seulement _page_editor_scripts.html :
  window.FlowDeckDB etait undefined dans l'iframe -> une base ouverte en side
  peek restait a « Loading database... ». Include _database_table_scripts.html
  ajoute (meme traitement que page_editor.html).
- Le panneau #db-side-peek n'existait que dans _database_table.html : hors page
  dediee, openPageInSidePeek etait un no-op. Desormais cree a la demande par
  ensureDbPeek (database_table.js) ; markup unique retire du partial.
- .database-table-container : plafond 1100px retire (regle heritee de Library)
  + max-width 100% pour .db-first et database-page-title : le tableau prend la
  largeur du document, plus de barre de scroll parasite sous le tableau.
- Tests: test_embed_editor_can_render_inline_databases. Suite 1265/0 (-n auto)
  + ruff 0. Bump VERSION/main.py 7.50.1, CHANGELOG, WORKLOAD, OpenAPI.
2026-10-06 15:15:01 -04:00
bruno 3706689eca fix: side peek = document seul en edition + bouton Open calendrier/Kanban (v7.50.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 13m47s
FlowDeck CI / docker (push) Skipped
- Les 4 peeks (library.js, local_workspace.js, my_tasks.js, database_table.js)
  chargent /pages/{id}?embed=1 : le panneau ne montre plus que le document en
  mode edition, sans sidebar ni barre (regression v7.49.0). Le cadre du
  panneau (close/full/resize partages fdWirePeekResize) est inchange.
- Calendrier My Tasks : le handler testait e.target.dataset.openRow mais les
  boutons .mt-cal-open ne contiennent que leur <svg> -> clic sur l'icone =
  e.target = <svg>, rien ne partait. cause racine corrigee par
  t.closest('[data-open-row]') : un seul chemin tableau/calendrier/Kanban.
- Kanban My Tasks : meme bouton Open ajoute sur les tuiles (openButton(t),
  overlay absolu revele au survol de la carte .mt-card).
- Menu contextuel global « Open in side peek » : etait un toast d'erreur ;
  route maintenant vers le peek de la page courante (_libData.openPeekById /
  _wsData.openSidePeek), repli nouvel onglet ailleurs.
- /pages/{id}?embed=1 sur une page de base (content_format=collection)
  rendait un editeur de blocs vide : le template collection est garde en
  embed (body.embed-mode retire sidebar+barre, le tableau reste). Bouton
  pleine page des peeks de bases : navigue via dataset.page (URL propre)
  au lieu du src ?embed=1.
- Tests : garde de coherence inversee (?embed=1 exige sur les 4 fichiers),
  +3 nouveaux (closest, tuiles Kanban, collection embed). Fix d'un test
  pre-existant casse (assertion panel.style.display, reecriture vanilla JS
  v7.49.0). Suite 1264 passed / 0 failed (-n auto) + ruff 0.
- Bump VERSION/main.py 7.50.0, CHANGELOG, WORKLOAD, OpenAPI regenere.
2026-10-06 12:48:48 -04:00
bruno 0f86294abc merge: main → develop (sync) 2026-07-20 11:02:42 -04:00
58 changed files with 5992 additions and 397 deletions
+10
View File
@@ -20,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET=
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
OAUTH_REDIRECT_URI=
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
# Vidés = connecteur « non configuré » (le menu + affiche la raison).
# Le redirect URI à enregistrer est dérivé d'APP_BASE_URL :
# {APP_BASE_URL}/api/agent/connectors/oauth/google/callback
# {APP_BASE_URL}/api/agent/connectors/oauth/ms365/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
MS_CLIENT_ID=
MS_CLIENT_SECRET=
# ── App ──
APP_SECRET_KEY=change-me-to-random
APP_HOST=0.0.0.0
+460
View File
@@ -1,5 +1,465 @@
# Changelog - FlowDeck
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
### Fixed
- **Aide** — bouton **✕** en haut à droite du panneau (classe partagée
`settings-close` : 32 px desktop / **44 px mobile**) + méthode `close()` dans
`help.js` (retour à la page précédente, sinon `/workspaces`) ;
`.settings-panel` passé en `position:relative` pour ancrer les boutons au
panneau (hamburger + ✕) plutôt qu'à la page.
- **Éditeur — « Insert below » (/Write with AI)** : on insère le **markdown
source** conservé à la génération (`AIC._md`) au lieu du texte extrait du
HTML rendu (`_resultText()` ne reste qu'en repli pour les messages d'erreur) —
titres, listes, gras et numéros survivent à l'insertion.
- **Listes numérotées** — elles n'avaient **aucun numéro au rendu** : le CSS
ne déclarait que le retrait, jamais le marqueur. Le rang des blocs
`numbered_list` contigus est désormais calculé au rendu (`data-num`) et
affiché via `content: attr(data-num) ". "` → **`/numbered list` et
l'insertion depuis l'assistant affichent enfin 1, 2, 3**.
- **`md2b`** (markdown → blocs, chemin agent) : les cases à cocher `- [ ]` /
`- [x]` sont testées **avant** la branche des puces (une ligne `- [ ]`
était avalée par cette branche) — aligné sur l'ordre déjà correct de
`paste2b` ; le séparateur `1)` est accepté (CommonMark).
- **Menu slash** — une requête tapée **dans le bloc** (`/numbered list`, focus
qui n'a pas rejoint l'entrée du menu) est répercutée dans le filtre au lieu
d'être ignorée.
- **Tests** — `tests/test_v7592_ui_fixes.py` : **5 tests**, dont 1 test
comportemental **node** qui exécute le vrai `md2b` extrait de la source.
- `ruff` : ordre d'imports de `tests/test_agent.py` (I001 préexistant au pull).
## v7.59.1 (2026-10-07) — Fix fournisseur Mistral (clé valide, 403 tier_not_allowed)
### Fixed
- **Mistral « Test connection » échouait malgré une clé valide** — cause
racine : `mistral-large-latest` (modèle par défaut + tête de liste) n'est
pas servi par les plans d'abonnement basiques → `403 tier_not_allowed`
(code 1910). La clé elle-même fonctionne (`/v1/models` 200, chat 200 sur
small/medium/nemo/codestral — vérifié en direct).
- `PROVIDERS["mistral"]` default → `mistral-small-latest` (tous plans).
- `PROVIDER_MODELS["mistral"]` réordonnée : modèles tous-plans d'abord
(le test de connexion sélectionne le premier candidat).
- `mistral` ajouté à `_CHAT_VALIDATED_PROVIDERS` : le fetch des modèles
sonde chat/completions et exclut les modèles hors plan (46 listés →
25 réellement utilisables avec la clé du compte).
- **Migration 36** : `default_model` des lignes mistral pointant sur un
modèle bloqué réinitialisé (→ nouveau défaut), `llm_config.model` idem.
### Tests
- `tests/test_agent.py::test_mistral_defaults_are_tier_safe` · suite agent
OK · test de connexion live sur l'instance déployée : `{"ok":true,
"model":"mistral-small-latest","reply":"PONG","verified":true}`.
## v7.59.0 (2026-10-07) — Mobile : drawer réglable, side-nav settings, aide refondue
### Fixed
- **Scroll souris du drawer sidebar mobile** — `display:block` du breakpoint
≤768px cassait le `flex:1` de `.sidebar-scroll` (hauteur = contenu, jamais
de zone scrollable). Remis en `display:flex;flex-direction:column`.
- **Rate limiter du routeur de test** — fenêtre remise à zéro à chaque login
: suite e2e via un seul login `beforeAll` + cookies `storageState`.
### Changed
- **Settings mobile : le menu devient une side-navigation gauche** ouverte
par un hamburger (`.settings-menu-btn`) avec backdrop, au lieu des onglets
horizontaux ; fermeture auto au choix de section. Squelette panneau
(overlay/panel/nav) extrait de `settings.html` vers
`/static/css/settings.css` partagé avec l'aide.
- **Page `/help` refondue au complet** — ancien monologue HTML inline de la
route remplacé par `app/templates/help.html` : même panneau 2 colonnes que
settings, nav latérale en 13 sections couvrant toute l'app (démarrer,
éditeur, bases & vues, tâches/dépendances, workspaces/forges, collaboration,
bibliothèque/recherche, agent IA & automations, API v2/webhooks/clipper,
PWA offline, comptes & SSO, raccourcis, tips) ; `static/js/help.js`
(`Alpine.data helpInit`).
### Tests
- Gate e2e `mobile_regression.spec.js` : 6/6 (drawer + wheel scroll +
side-nav settings + aide mobile/desktop) · `test_app.py` +
`test_pwa_offline.py` : 230/230.
## v7.58.0 (2026-10-06) — Add plugins : catalogue on/off à effet réel (phase 8/8)
### Added
- **`app/services/plugins.py` + migration 35** — table `plugins` (`slug`,
`name`, `description`, `enabled`) pré-remplie avec **3 modules câblés** :
`web-tools`, `web-clipper`, `automations`. Ligne absente = activé (défaut
sûr : rien n'est coupé par accident).
- **Effet réel, jamais un simple drapeau** :
- `automations` OFF → dépendance FastAPI posée **à l'`include_router`**
(3 lignes dans `main.py`, aucun router touché) → toutes les routes
`/workspace/automations*` refusées, **et** le scheduler de fond passe en
veille (garde sur chaque tick) ;
- `web-clipper` OFF → `GET /extensions` + tout `/api/v2/web-clipper/*`
refusés ;
- `web-tools` OFF → `web_search` et `fetch_url` retirés du **schéma** de
l'agent et de `execute()` (`ToolRegistry._all()`) : le LLM ne les voit plus.
- **UI masquée côté serveur** — nouveau global Jinja `plugin_enabled(slug)` :
`settings.html` rend la nav « Extensions » / « Automations » sous
`{% if %}` (élément **absent** du DOM, pas seulement caché) et conditionne
les sections en `x-show`.
- **Menu +** — l'entrée « Add plugins » devient une vraie section : liste des
3 plugins (état + description) avec bascule ;
`GET /api/agent/plugins` + `PATCH /api/agent/plugins/{slug}` (slug inconnu →
404, 401 sans session).
- **Tests** — `tests/test_v758_plugins.py` : **10 tests** — catalogue,
persistance en base, routes refusées (302 hors `/api` + 404 JSON pour
`/api*`, handler unifié de `main.py`), outils retirés du registre, nav
disparue du `/settings` rendu, câblage du menu, 401.
## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8)
### Added
- **Presets Discord / Telegram** — champ **Type** dans le formulaire « Ajouter
un connecteur » (Personnalisé / Discord / Telegram / Serveur MCP) : URL et
schéma d'authentification pré-remplis. Colonnes `kind` + `auth`
(`bearer`/`bot`/`none`) ajoutées à `agent_connectors` (migration **34**) ;
Discord envoie son jeton préfixé dans l'en-tête d'autorisation, Telegram place
le jeton **dans l'URL** via le substitut `{secret}` — substitué à l'appel,
**jamais stocké en clair** (la colonne url ne contient que le motif).
- **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
(consentement admin requis à la reconnexion).
- **`app/services/mcp_client.py`** — client MCP (streamable HTTP) : handshake
`initialize` → `notifications/initialized` → `tools/list` → `tools/call`
(JSON-RPC 2.0, réponse JSON **ou** premier `data:` d'un `text/event-stream`),
garde SSRF conservée, erreurs JSON-RPC remontées telles quelles, sortie
bornée à 20 000 car. Les outils sont **cachés** en base (`tools_json`) — le
bouton « Tester le connecteur » rejoue le handshake et met à jour la liste.
- **Outils dynamiques au registre** — `ToolRegistry._all()` merge ce cache à
chaque run : chaque outil est exposé au LLM sous `mcp_<serveur>_<outil>` avec
son `inputSchema` d'origine, et `execute()` dispatche sur `tools/call`.
Serveur désactivé → outil **absent du schéma** ; échec réseau →
`ToolResult(error)` sans casser le run.
- **Menu +** — badge `· N outil(s)` sur la fiche d'un serveur MCP ;
`POST /api/agent/connectors` accepte `kind` + `auth` (400 sur valeurs
inconnues).
- **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel réseau
réel (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la base,
handshake + séquence d'appels, cache, dispatch LLM, serveur désactivé,
erreurs RPC, parse SSE, scopes Teams, câblage menu.
## v7.56.0 (2026-10-06) — Connecteurs : Google + Microsoft 365 (phase 6/8)
### Added
- **`app/services/oauth_connectors.py`** — flow OAuth2 complet **PKCE (S256)** pour
2 fournisseurs décrits par 1 dict :
- **Google** : Drive / Gmail / Calendar **en lecture seule** ;
- **Microsoft 365** : Graph `Files.Read` / `Mail.Read` / `Calendars.Read` ;
- `begin()` → URL d'autorisation + state + code_verifier ; `complete()` →
échange du code ; `access_token()` → **refresh automatique** (60 s avant
expiration, `refresh_token` conservé si le fournisseur n'en renvoie pas) ;
`api_get()` → lecture bornée, `path` absolu refusé + validation SSRF ;
- tokens chiffrés **Fernet** via `_encrypt_tokens` de `calendar_sync`
(réutilisation, aucune nouvelle dépendance) dans la table
**`connector_tokens`** (migration **33**, PK `(kind, user_id)`).
- **4 routes OAuth** (`/api/agent/connectors/oauth/{kind}/…`) : `status`,
`authorize` (cookies d'état HttpOnly 10 min + retour same-origin validé),
`callback` (GET = SAFE_METHODS, `state` comparé en temps constant, tokens
stockés puis cookies purgés, redirection `?oauth=connected` /
`?oauth_error=…`), `disconnect`. **OpenAPI : 523 chemins**.
- **Config + `.env.example`** : `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET`
(vidés = « non configuré »), redirect URI dérivé d'APP_BASE_URL.
- **Catalogue** : les 2 nouveaux natifs apparaissent avec le badge
« non connecté — lancer la connexion OAuth » / « connecté · N scope(s) » ;
`connector_fetch` et `Tester` passent par l'API Graph/Google avec le token de
l'utilisateur (outil LLM = `user_id` désormais transmis).
- **Menu +** : « Se connecter » (redirige vers le fournisseur) / « Déconnecter »
sur la fiche du connecteur, + toast au retour du flux (URL nettoyée via
`history.replaceState`).
### Tests
- `tests/test_v756_oauth_connectors.py` — **13 tests** : URL d'autorisation
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
**chiffrés** en base + redirection + `status.connected`, **state forgé refusé
sans aucun appel réseau**, callback sans session → `oauth_error=session`,
refresh (grant_type, conservation du refresh_token), `api_get` (Bearer + hôte
fixe + URL absolue refusée), non connecté, déconnexion, catalogue, outil LLM,
401 sans session, 404 kind inconnu, câblage menu. Aucun appel réseau réel
(`_post_form` / `_api_get` monkeypatchés).
- `test_v755` adapté : **5 natifs** (gitea, github, web, google, ms365).
- **Suite complète : 1319 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8)
### Added
- **Table `agent_connectors`** (migration **32**) : `name`, `url`,
`secret_encrypted`, `enabled`, `status`, `detail` — colonnes **plates**, pas de
`config_json` (les scopes OAuth des phases 6-7 ajouteront le leur).
- **`app/services/connectors.py`** — 1 fichier (pas de package) :
- **3 connecteurs natifs** (`gitea`, `github`, `web`) servis à la volée, statut
dérivé de la config **sans réseau** (`native_state()`) ;
- CRUD des connecteurs personnalisés : l'URL passe par **`_validate_url`
(garde SSRF)** à la création, la clé est **chiffrée Fernet** (`encrypt_secret`)
et **jamais renvoyée** (seul `has_secret` l'est) ;
- `probe()` : appel de contrôle (`_get`, timeout 10 s, re-vérification de
l'hôte après redirection) qui **persiste** `status`/`detail` ;
- `connector_fetch()` : lecture pour l'LLM — API Gitea/GitHub (native),
recherche web (native) ou GET du connecteur personnalisé, borné à 20 000 car.
- **API** (`/api/agent/connectors`) — `GET` (catalogue), `POST` (création, 400 sur
URL privée), `PATCH` (name/enabled/secret), `DELETE`, `POST /connectors/probe`
; session requise partout (401), CSRF global. **OpenAPI : 519 chemins**.
- **Outil LLM `connector_fetch`** (`tool_registry`, 26ᵉ outil) — args
`connector` (id / nom / kind), `path`, `query` ; connecteur désactivé ou
inconnu = erreur outil, **jamais une exception qui casse le run**.
- **Menu + : section « Connecteurs »** — catalogue avec badge de statut
(✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — les
2 derniers masqués pour les natifs) et formulaire « Ajouter un connecteur
personnalisé » (nom, URL, clé en `type="password"`).
### Tests
- `tests/test_v755_connectors.py` — **13 tests** : 3 natifs listés, clé jamais
renvoyée + chiffrée en base (roundtrip Fernet), **5 URLs refusées** (localhost,
127.0.0.1, metadata cloud, ftp:, file:), toggle persisté + 404, 401 sans
session, probe OK (clé bien envoyée) / probe erreur persistée, outil
`connector_fetch` (schéma, succès, désactivé, inconnu), câblage du menu.
- `test_v751`/`test_v753`/`test_v754` adaptés : **1 seule section « bientôt »**
(plugins).
- **Suite complète : 1306 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8)
### Added
- **Mémoire de l'agent** — le moteur n'envoie jamais l'historique des messages
(`AgentEngine.run()` ne construit que système + objectif courant) : chaque run
repartait de zéro. Désormais une **ligne résumé par conversation**
(`app/services/agent_memory.py`, table `agent_memory`, migration 31) est
écrite à la fin de chaque run et **ré-injectée au contexte du run suivant**
quand le toggle est activé.
- `context_for()` : bloc « ## Mémoire de la conversation », **budget 4 000
caractères** (troncature par la gauche avec marqueur) ;
- `remember()` : 1 note par échange (objectif 180 car. → réponse 700 car.),
**20 notes max**, upsert, et **jamais une erreur ne fait échouer un run** ;
- OFF = ni lecture ni écriture (contexte strict du run courant).
- **Toggle « Mémoire » dans le menu +** — entrée racine à l'état réel
(🧠 activée / 💭 désactivée avec le libellé correspondant), bascule via
`PATCH /api/agent/conversations/{id}` (`memory_enabled` ajouté au whitelist),
état persisté et reflété à l'ouverture/à la création de la conversation.
- **`agent_conversations.memory_enabled`** (migration **31**, défaut `1`) +
config **`AGENT_MEMORY_DEFAULT`** (`settings.agent_memory_default`) appliquée
à la création d'une conversation ; la réponse de création expose la valeur.
### Tests
- `tests/test_v754_memory.py` — **8 tests** : colonne + table créées par la
migration, défaut ON à la création, toggle persisté relu en base,
injection ON / absence OFF / persistance à travers un cycle OFF→ON,
budget et nombre de notes bornés, **2 runs réels du moteur** (FakeLLM qui
capture le prompt : mémoire présente ON, absente OFF), câblage front/back.
- `test_v751` + `test_v753` adaptés : **2 sections « bientôt »** restantes
(connecteurs, plugins).
- **Suite complète : 1293 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.53.0 (2026-10-06) — Menu + : Design System – Canevas (phase 3/8)
### Added
- **Section « Design System – Canevas »** — liste des canevas
(`GET /board/api/page-templates` : intégrés + personnels), puis une page de
détail avec 3 actions :
- **Créer une page à partir du canevas** (`POST …/0/use` avec `{key}` *dans le
body* pour un intégré, `POST …/{id}/use` pour un personnel) ;
- **Insérer dans le document ouvert** — `GET …/blocks` (nouvelle route) →
`ensureBlockIds()` (global côté éditeur) → concaténation dans `E.blocks` +
`autoSave/render`, désactivé si aucun document éditable n'est ouvert ;
- **Enregistrer le document ouvert comme canevas**
(`POST /board/api/page-templates` avec `page_id`).
- **`GET /board/api/page-templates/{template_id}/blocks`** — blocs d'un canevas
( intégré via `template_id=0&key=`, sinon id) ; 404 clé/id inconnu,
500 si le JSON est corrompu. **OpenAPI : 516 chemins**, `info.version` 7.53.0.
- **Canevas « Design System »** intégré (`design_system` dans
`app/services/block_templates.py`) : callout tokens, TOC, 3 toggles
composants, grille & espacement, checklist revue UI, citation.
- **Navigation** : en-tête ← gère le niveau canevas → liste.
### Fixed
- **Prefixe des canevas** : le front du panneau utilisait `/api/page-templates`
(404 silencieux) — la route réelle est `/board/api/page-templates` (router
`prefix="/board"`), comme `base.html` le fait déjà.
### Tests
- `tests/test_v753_canvases.py` — **5 tests** : canevas `design_system` listé,
route blocks intégré (sans `id`, 404 clé inconnue, `empty` par défaut),
route blocks personnel + 404, création de page depuis le canevas (blocs lus
en base, ids assignés), câblage des sections (3 « bientôt » restantes) +
garde-fou « `key` dans le body ». `test_v751` adapté (3 sections désactivées).
- **Suite complète : 1285 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.52.0 (2026-10-06) — Menu + : Compétences-skills « Gérer » + « Parcourir » (phase 2/8)
### Added
- **Sous-menu « Compétences-skills »** — 11 skills builtin + les skills
enregistrés, avec **libellés affichés** « Deep research » (`/research`) et
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS` (slugs intacts) ;
le clic épingle le skill au contexte, même chemin que la palette `/`.
- **« Gérer les compétences »** — section du menu : liste des skills,
formulaire intégré (nom, description, prompt) pour créer/éditer, suppression
avec nettoyage du chip épinglé, **export JSON** téléchargé et **import** en
fichier (`overwrite:true` → jamais de doublon).
- **« Parcourir les compétences »** — section galerie filtrable (nom /
description / slug), installation en 1 clic (`installGallerySkill`).
- **`PATCH /api/agent/skills/{skill_id}`** — édition partielle, whitelist de
colonnes figée côté serveur, réponses 400 (champ vide / aucun champ),
401 (sans session), 404, 409 (nom déjà pris). **OpenAPI régénéré** :
`docs/openapi-v2.json` → 515 chemins, `info.version` 7.52.0.
- **En-tête ← + titre de section** dans le menu (indispensable à 3 niveaux :
racine → compétences → gérer/parcourir/formulaire).
- **Fix** : épingler un skill enregistré **n'efface plus** le message déjà tapé
(`_pinDbSkill` ne vide le composer que s'il est vide).
### Tests
- `tests/test_v752_skills_menu.py` — **7 tests** : PATCH complet + partiel,
validations 400/404, 401 sans session (CSRF valide pour isoler la route),
import sans doublon (409 puis `overwrite` → 1 seule ligne), aller-retour
export → import, câblage des sections + alias, markup rendu dans la page.
- `tests/test_v751_plus_menu.py` adapté : 4 sections « bientôt » au lieu de 5.
- **Suite complète : 1280 passed / 0 failed** (`-n auto`), `ruff check app tests`
propre, `eslint static/js/agent_panel_2.js` **0 problème**.
## v7.51.0 (2026-10-06) — Menu + de l'assistant : hub de contexte (phase 1/8)
### Added
- **Menu à sections sur le bouton +** (`agent_panel.html` + `agent_panel_2.js`) :
le + ouvre *Ajouter des fichiers ou répertoires*, *Compétences-skills*,
*Connecteurs*, *Design System – Canevas*, *Add plugins*, *Mémoire (on/off)*.
Sections pas encore livrées affichées « bientôt (phase N) » et **désactivées**
(décision D3 : le menu ne promet rien que le code ne fait). Navigation clavier
↑/↓/Entrée/Échap, focus visible, Échap revient d'un niveau.
- **Parcours fichiers & répertoires** — « Parcourir… » affiche un niveau à la fois
via `/api/nav/menu` (contrat : dossier = `icon === 'folder'`) avec fil d'Ariane
cliquable ; un document s'épingle (`document:<id>`), un dossier s'épingle depuis
la ligne « 📌 Épingler le dossier » (`folder:<id>`).
- **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` : titre du
dossier + documents directs, budget ~12k caractères (marqueur « contenu du
dossier tronqué »). Enfants directs seulement — récursion à ajouter si les
arbres profonds deviennent réels.
- **« Rechercher… »** conserve l'ancien sélecteur de mentions (recherche par nom,
`@` inline) à l'identique : zéro régression du chemin existant.
### Tests
- `tests/test_v751_plus_menu.py` — **7 tests** : résolution du dossier, budget
borné, id inconnu, contrat `/api/nav/menu`, rendu du menu dans la page,
6 sections dont 5 désactivées, jeton `folder:` côté front et côté backend.
- **Suite complète : 1273 passed / 0 failed** (`-n auto`), `ruff check app tests`
propre, `eslint static/js` **0 erreur**.
## v7.50.2 (2026-10-06) — Peek de Library : nu comme local-workspace (plus de bande ni double scrollbar)
### Fixed
- **Le document ouvert par le bouton « Open » de Library n'occupait pas le
panneau** — `.peek-body` gardait un `padding:16px 20px` + `overflow-y:auto`
(héritage de l'ancien peek à rendu HTML) : l'iframe était amochée
(519×816 mesurés contre 559×848 en local-workspace) avec une bande grise
autour du document et une double scrollbar. Corps nu (`padding:0`,
`overflow:hidden`) : l'éditeur remplit le panneau, exactement comme le peek
« parfait » de /local-workspace (mesuré Playwright, cookie de session forgé).
### Tests
- `test_library_peek_body_has_no_inset_padding`. Suite 1266 passed / 0 failed
(-n auto) + ruff 0.
## v7.50.1 (2026-10-06) — Peek des bases : le contenu se rend enfin + tableau pleine largeur
### Fixed
- **Une base ouverte en side peek restait à « Loading database… »** —
`page_editor_embed.html` (le peek de `/pages/{id}?embed=1`) n'incluait pas
`database_table.js` : `window.FlowDeckDB` était undefined dans l'iframe et
le bloc `embed/collection` ne s'hydrat jamais. Inclus désormais (même
traitement que `page_editor.html`).
- **L'icône « Open » de fin de ligne d'un tableau de base ne faisait rien
hors page dédiée** — le panneau `#db-side-peek` n'existait que dans le
markup de `_database_table.html` ; sans lui, `openPageInSidePeek` était un
no-op. Le panneau est créé à la demande par `database_table.js`
(`ensureDbPeek`, comme `mt-peek` de My Tasks) : markup unique, le partial
ne contient plus le panneau statique.
- **Barre de scroll sous le tableau des bases** — `.database-table-container`
plafonnait à 1100 px (règle héritée de Library) : le tableau ne prenait
pas la largeur du document. Plafond retiré ; `max-width: 100%` aussi pour
les conteneurs `.db-first` (base embarquée en premier bloc) et le titre
des pages de base — les colonnes dictent seules le scroll horizontal.
### Tests
- `test_embed_editor_can_render_inline_databases` (include peek + panneau
créé à la demande, source unique de markup). Suite 1265 passed / 0 failed
(-n auto) + ruff 0.
## v7.50.0 (2026-10-06) — Side peek : le document seul (mode édition), sans sidebar ni barre
### Fixed
- **Tous les boutons « Open » ouvraient l'habillage pleine page dans le panneau
latéral** — `library.js` (OPEN de hover), `local_workspace.js` (↢ Open +
« Open in side peek » du menu contextuel) et `my_tasks.js` (peek des tâches)
chargeaient `/pages/{id}` SANS `?embed=1` : le peek affichait sidebar +
barre de navigation + éditeur (régression v7.49.0). Tous repassent en
`/pages/{id}?embed=1` = le document en mode édition SEUL ; le cadre du
panneau (garde/close/resize partagés `fdWirePeekResize`) reste identique.
- **Le bouton « Open » du calendrier My Tasks ne faisait rien** — cause
racine : le handler de clic testait `e.target.dataset.openRow`, mais les
boutons `.mt-cal-open` du calendrier (et du Kanban) ne contiennent que leur
`<svg>` : cliquer l'icône donnait `e.target` = `<svg>` sans dataset. Le
handler résout désormais `t.closest('[data-open-row]')` — un seul chemin
pour tableau, calendrier (5 vues) et Kanban.
- **Menu contextuel global « Open in side peek »** — le clic ne faisait
qu'afficher « Side peek not available in browser » : il route maintenant
vers le peek de la page courante (`_libData.openPeekById` en Library,
`_wsData.openSidePeek` en workspace local), repli nouvel onglet ailleurs.
- **Peek d'une page de base (`content_format='collection'`)** — `?embed=1`
forçait le template éditeur : une base ouverte dans le peek montrait un
éditeur de blocs vide au lieu du tableau. Le template collection est gardé
en mode embed (body.embed-mode retire sidebar + barre, le tableau reste).
### Added
- **Bouton « Open » sur les tuiles du Kanban My Tasks** — le même que les
lignes du tableau (`openButton(t)`, `.mt-hover-only` révélé au survol de la
carte, overlay absolu pour ne pas déformer la tuile) ; ouvre le contenu de
la tâche dans le panneau latéral.
- **Bouton « pleine page » des peeks de bases** — navigue vers
`/pages/{pageId}` (pleine page, sidebar + barre conservées) au lieu de
l'URL `?embed=1` de l'iframe ; `dataset.page` porte l'id résolu.
### Tests
- `test_my_tasks_workspace_coherence.py` : les 4 peek JS (`library`,
`my_tasks`, `database_table`, `local_workspace`) doivent porter
`?embed=1` (gate de cohérence inversé — l'ancien test imposait l'URL nue) ;
nouveaux : résolution `closest('[data-open-row]')`, bouton Open sur tuiles
Kanban, collection embed = tableau. Suite 1264 passed / 0 failed
(-n auto) + ruff 0.
## v7.49.2 (2026-10-05) — Pages de base : titre au-dessus, tableau à gauche
### Fixed
+245
View File
@@ -284,6 +284,251 @@ 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-8 ✅ 2026-10-06 — menu + complet)
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
> un menu à sections, tel que demandé :
>
> ```
> + ├─ Ajouter des fichiers ou répertoires … → picker actuel + parcours de l'arborescence
> ├─ Compétences-skills … … … … … … … … → Deep research, Skill-creator, … + Gérer + Parcourir
> ├─ Connecteurs … … … … … … … … … … … → Parcourir + Ajouter un connecteur personnalisé
> ├─ Design System – Canevas … … … … … → galerie de canevas (templates de page)
> ├─ Add plugins … … … … … … … … … … … → catalogue de modules on/off
> └─ Mémoire (on/off) … … … … … … … … → mémoire persistante de l'agent
> ```
>
> **Doc de design** : [`docs/V74_Agent_Plus_Menu.md`](docs/V74_Agent_Plus_Menu.md)
> — maquette, découpage front/back, modèle de données, endpoints, décisions, pièges.
**État des lieux lu dans le code le 2026-10-06** (constat **avant** la phase 1) :
- Le **+** = `toggleAddPicker()` (`static/js/agent_panel_2.js:1074`) → menu **plat** de
mentions (`GET /api/agent/mentions`, `app/routers/agent.py:596`) : documents,
collections, pages. **Pas de dossiers, pas de sous-menu, pas de section.**
- **Compétences** : 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) +
table `agent_skills` (CRUD `/api/agent/skills`) + galerie de 17 presets — mais
**aucune UI « Gérer » ni « Parcourir »** : tout passe par la palette `/`.
- **Canevas** : `GET /api/page-templates` (built-ins `app/services/block_templates.py` +
personnels) et `POST /api/page-templates/{id}/use` existent déjà, **jamais appelés
depuis le panneau agent**.
- **Connecteurs / Plugins / Mémoire** : **inexistants** — 0 hit `connector|plugin|memory`
dans `app/` hors « in-memory ».
**Ordre** : phases 1 → 8, chacune indépendante et livrable (version + tests verts +
CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
### Phase 1 — v7.51.0 — Menu hub + fichiers/répertoires · effort S–M ✅ (livrée 2026-10-06)
- [x] **Structure du menu** — sections + sous-menus, navigation clavier (↑/↓/Entrée/Échap),
styles réutilisés depuis `.fd-ap-mention-menu` ; les sections pas encore livrées
s'affichent « bientôt » et désactivées (le menu ne promet rien que le code ne fait)
→ `FD_PLUS_MENU` / `FD_PLUS_FILES` (`static/js/agent_panel_2.js`), markup
`.fd-ap-plus-menu` (`agent_panel.html`), Échap remonte d'un niveau, la frappe
referme le menu
- [x] **Fichiers & répertoires** — picker actuel (recherche) **+ mode « Parcourir… »** :
arborescence **un niveau par appel via `GET /api/nav/menu?parent_id=`**
(contrat : dossier = `icon === 'folder'` — plus simple que le tree récursif,
déjà testé), fil d'Ariane cliquable ; un répertoire s'épingle comme un fichier
(ligne « 📌 Épingler le dossier »)
- [x] **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` →
`_single_folder()` : titre du dossier + documents directs, budget ~12k caractères
(marqueur « tronqué ») ; enfants directs seulement (récursion si besoin réel)
- [x] **Tests** — `tests/test_v751_plus_menu.py` : **7 tests** (résolution, budget,
id inconnu, contrat `/api/nav/menu`, rendu du menu, 6 sections / 5 désactivées
à la livraison de la phase 1, jeton `folder:` front+back) ; **suite complète
1273 verts** à la livraison, `ruff` 0, `eslint` 0 erreur
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.51.0**, CHANGELOG, ce ROADMAP
### Phase 2 — v7.52.0 — Compétences : « Gérer » + « Parcourir » · effort M ✅ (livrée 2026-10-06)
- [x] **Sous-menu Compétences** — 11 builtin + skills enregistrés listés, le clic
épingle le skill au contexte (même chemin que la palette `/`, comportement
conservé)
- [x] **Libellés affichés** — alias « Deep research » (`/research`) et
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS`, slugs intacts
- [x] **« Gérer les compétences »** — **section du menu + formulaire intégré**
(nom, description, prompt) plutôt que modale : CRUD `POST`/`PATCH`/`DELETE`
sur `/api/agent/skills` — **`PATCH /api/agent/skills/{id}` créé** (champs
présents seuls, whitelist de colonnes, 400/401/404/409) + **export JSON**
(`/skills/{id}/export` → téléchargement) et **import fichier**
(`/skills/import`, `overwrite:true` → pas de doublon) ; suppression = nettoyage
du chip épinglé
- [x] **« Parcourir les compétences »** — section galerie sur
`GET /api/agent/skills/gallery`, **filtre** (nom/description/slug),
installation en 1 clic (réutilise `installGallerySkill`)
- [x] **En-tête ← + titre** dans le menu (navigation à 3 niveaux)
- [x] **Fix** — épingler un skill enregistré n'efface plus le message tapé
- [x] **Tests** — `tests/test_v752_skills_menu.py` : **7 tests** (PATCH complet +
partiel, 400/404, 401 sans session, import sans doublon, export→import,
câblage sections/alias, markup) ; `test_v751` adapté (4 sections « bientôt ») ;
**suite complète 1280 verts**, `ruff` 0, `eslint` 0 problème
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.52.0**, OpenAPI régénéré
(515 chemins), CHANGELOG, ce ROADMAP
### Phase 3 — v7.53.0 — Design System – Canevas · effort M ✅ (livrée 2026-10-06)
- [x] **Sous-menu Canevas** — `GET /board/api/page-templates` (built-ins **+
personnels**) ; **piège confirmé** : le router est sous `prefix="/board"` →
l'URL réelle est `/board/api/page-templates`, pas `/api/page-templates`
(404 silencieux corrigé dans le front)
- [x] **Deux actions (3 en réalité)** — page de détail par canevas :
« Créer une page à partir du canevas » (`POST …/0/use` avec `{key}` **dans le
body** — la clé en query est ignorée par la route), « Insérer dans le document
ouvert » (`GET …/blocks` **nouvelle route** → `ensureBlockIds()` global →
concat dans `E.blocks` + `autoSave/render`, désactivé sans document ouvert)
et « Enregistrer le document ouvert comme canevas » (`POST` + `page_id`)
- [x] **Canevas « design system »** — preset `design_system` dans
`block_templates.py` (callout tokens, TOC, toggles composants, grille,
checklist revue UI) ; `design-tokens.css` référencé dans le callout
- [x] **Sauver le doc ouvert comme canevas** — `POST /board/api/page-templates`
(existante) branchée depuis le menu, nom via `prompt()` natif
- [x] **Tests** — `tests/test_v753_canvases.py` : **5 tests** (preset listé,
route blocks intégré/personnel + 404, création de page vérifiée en base avec
ids, câblage 3 « bientôt » + garde-fou `key`-dans-le-body) ; `test_v751`
adapté ; **suite complète 1285 verts**, `ruff` 0, `eslint` 0 problème
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.53.0**, OpenAPI régénéré
(516 chemins), CHANGELOG, ce ROADMAP
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M ✅ (livrée 2026-10-06)
- [x] **Table `agent_memory`** — **une ligne résumé par conversation**
(`conversation_id` PK, upsert) + colonne `agent_conversations.memory_enabled`
(migration **31**, défaut `1`) ; la variante « mémoire d'espace » prévue au
design est **remise** (YAGNI : rien ne l'écrit — `ponytail:` noté dans le service)
- [x] **Service `app/services/agent_memory.py`** — **extraction déterministe**
(pas d'LLM) : 1 note par échange (objectif 180 car. → réponse 700 car.),
20 notes max, écrite en fin de run via `agent_memory.remember()` ; toute
erreur est journalisée, **un run ne peut pas échouer à cause de la mémoire**
- [x] **Injection** — `AgentEngine.run()` appelle `agent_memory.context_for()` après
`ContextBuilder.build()` : bloc « ## Mémoire de la conversation », **budget
4 000 caractères** (troncature par la gauche + marqueur) ; OFF = ni lecture ni
écriture → contexte strict du run courant
- [x] **Toggle « Mémoire »** dans le menu + — entrée racine à l'état réel
(🧠/💭 + libellé), `PATCH /api/agent/conversations/{id}` (`memory_enabled`
ajouté au whitelist), état lu à l'ouverture et à la création, défaut
`AGENT_MEMORY_DEFAULT` (config + réponse de création)
- [x] **Tests** — `tests/test_v754_memory.py` : **8 tests** dont **2 runs réels du
moteur** (FakeLLM capture le prompt : mémoire présente ON / absente OFF),
budget et nombre de notes bornés, toggle relu en base ; `test_v751` +
`test_v753` adaptés (2 sections « bientôt ») ; **suite complète 1293 verts**,
`ruff` 0, `eslint` 0 problème
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré
(516 chemins), CHANGELOG, ce ROADMAP
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L ✅ (livrée 2026-10-06)
- [x] **Table `agent_connectors`** (migration **32**) — colonnes **plates**
(`name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail`) ; le
`config_json` prévu est **remis** (YAGNI : rien ne le remplissait — les scopes
OAuth des phases 6-7 ajouteront le leur)
- [x] **`app/services/connectors.py`** — **1 fichier au lieu du package
`connectors/`** promis (3 probes + 1 fetch ne justifient pas une arborescence) ;
3 natifs `gitea`/`github`/`web` servis à la volée, statut **sans réseau**
(`native_state()`) ; `probe()` et `connector_fetch()` passent par `_get()`
(gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.)
- [x] **Menu « Connecteurs »** — « Parcourir » = catalogue avec badge
(✓ ok / ✗ error / ⚠ missing / ? inconnu) + fiche (Tester, Activer/Désactiver,
Supprimer — masqués pour les natifs) ; « Ajouter un connecteur personnalisé » =
formulaire nom + URL + clé (`type="password"`)
- [x] **Tool agent** — **un seul `connector_fetch`** (id/nom/kind + `path` +
`query`) au lieu d'un outil par connecteur : même capacité, moins de bruit de
schéma — l'outil dispatche vers Gitea/GitHub/web/personnalisé. Connecteur
désactivé ou inconnu = erreur outil, jamais une exception de run
- [x] **Sécurité** — `_validate_url` (SSRF) à la création **et** à chaque appel,
clé **chiffrée Fernet** et jamais renvoyée (`has_secret` seulement), 401 sans
session sur les 5 routes, CSRF global
- [x] **Tests** — `tests/test_v755_connectors.py` : **13 tests** (3 natifs, clé
chiffrée roundtrip, **5 URLs refusées** : localhost / 127.0.0.1 / metadata
cloud / ftp: / file:, toggle persisté, 401, probe OK + probe erreur persistée,
outil complet, câblage menu) ; 3 autres fichiers de phase adaptés (1 section
« bientôt » restante) ; **suite complète 1306 verts**, `ruff` 0, `eslint` 0
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré
(519 chemins), CHANGELOG, ce ROADMAP
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L ✅ (livrée 2026-10-06)
- [x] **OAuth2 PKCE Google** — Drive / Gmail / Calendar **en lecture seule** ;
connexion (`begin()` → URL + state + `code_verifier` S256), échange du code,
**refresh automatique** (60 s de marge, `refresh_token` conservé si absent de
la réponse), déconnexion — table `connector_tokens` (migration **33**,
PK `(kind, user_id)`), tokens chiffrés Fernet **réutilisant**
`calendar_sync._encrypt_tokens` (zéro dépendance ajoutée)
- [x] **OAuth2 Microsoft (Graph)** — `Files.Read` / `Mail.Read` / `Calendars.Read`
+ `offline_access`, même code (2 providers décrits par **1 dict**)
- [x] **Écran connecteur** — **pas de page dédiée** : l'état vit dans la fiche du
menu + (badge « connecté · N scope(s) » / « non connecté »), boutons
**Se connecter / Déconnecter**, toast au retour du flux (`?oauth=connected` /
`?oauth_error=` nettoyés par `history.replaceState`)
- [x] **Tests** — `tests/test_v756_oauth_connectors.py` : **13 tests**, **0 appel
réseau réel** (`_post_form` / `_api_get` monkeypatchés) — URL d'autorisation
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
chiffrés + redirection, **state forgé refusé sans échange**, callback sans
session, refresh, `api_get` (Bearer, URL absolue refusée), déconnexion,
catalogue, outil LLM, 401/404, câblage menu ; `test_v755` adapté (5 natifs) ;
**suite complète 1319 verts**, `ruff` 0, `eslint` 0
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.56.0**, `.env.example`
(GOOGLE_/MS_ CLIENT_ID/SECRET + redirect URI), OpenAPI régénéré (523 chemins),
CHANGELOG, ce ROADMAP
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L ✅
- [x] **Presets Discord / Telegram** — champ **Type** dans le formulaire
« Ajouter un connecteur » : `kind` + `auth` (`bearer`/`bot`/`none`) ajoutés à
`agent_connectors` (migration **34**) ; Discord = en-tête d'autorisation avec
le jeton préfixé, Telegram = jeton **dans l'URL** via le substitut `{secret}`
(remplacé à l'appel, **jamais stocké en clair** — colonne url = `…/bot{secret}`)
- [x] **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
(consentement admin)
- [x] **MCP complet** — `app/services/mcp_client.py` : `initialize` →
`notifications/initialized` → `tools/list` → `tools/call` (JSON-RPC 2.0,
réponse JSON **ou** 1er `data:` d'un `text/event-stream`), garde SSRF,
outils **cachés** en base (`tools_json`, maj par le bouton « Tester »)
- [x] **Outils dynamiques au registre** — `ToolRegistry._all()` merge le cache
MCP à chaque run : nom `mcp_<serveur>_<outil>` + `inputSchema` exposés au
LLM, `execute()` dispatche sur `tools/call` ; serveur désactivé → outil
**absent du schéma** ; erreur RPC → `ToolResult(error)` (jamais de run perdu)
- [x] **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel
réseau (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la
base, kind/auth invalides → 400, handshake + séquence d'appels, cache
`tools_json`, dispatch LLM, serveur désactivé, erreur handshake → probe
error, parse SSE/erreurs JSON-RPC, slug, scopes Teams, câblage menu
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.57.0**, OpenAPI régénéré,
CHANGELOG, ce ROADMAP
### Phase 8 — v7.58.0 — Add plugins · effort M ✅
- [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) —
migration **35**, table pré-remplie avec les **3 modules** ; ligne absente =
activé (défaut sûr)
- [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante
(fini le `disabled:true`) : liste des 3 plugins + bascule via
`GET/PATCH /api/agent/plugins[/slug]`
- [x] **Bascule on/off à effet réel** :
- `automations` → **toutes** les routes `/workspace/automations*` refusées
(dépendance FastAPI posée **à l'`include_router`**, aucun router touché) +
scheduler de fond en veille (garde par tick) ;
- `web-clipper` → `GET /extensions` + `/api/v2/web-clipper/*` refusés ;
- `web-tools` → `web_search` / `fetch_url` retirés du **schéma** et de
`execute()` (`ToolRegistry._all()`) ;
- UI → nav « Extensions » / « Automations » rendues sous
`{% if plugin_enabled(...) %}` (global Jinja, élément **absent** du DOM),
sections conditionnées en `x-show`
- [x] **Tests** — `tests/test_v758_plugins.py` : **10 tests** — plugin OFF =
route refusée (302 hors `/api`, 404 JSON pour `/api*`) + UI absente + outil
retiré, persistance, 401 sans session
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.58.0**, OpenAPI régénéré
(525 chemins), CHANGELOG, ce ROADMAP
---
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le
2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet** (aucune section
« bientôt »). Toute extension ultérieure = nouvelle phase à valider.
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
---
## 🔜 Prochaines versions
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
+1 -1
View File
@@ -1 +1 @@
7.49.2
7.59.2
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.49.2 (pages de base : titre au-dessus du tableau, tableau calé à gauche au ras du sidebar) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+13
View File
@@ -126,6 +126,19 @@ class Settings(BaseSettings):
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
# Client OAuth2 à créer dans les consoles : Google Cloud (OAuth client web)
# et Entra ID (application web). Le redirect URI est dérivé d'APP_BASE_URL.
google_client_id: str = ""
google_client_secret: str = ""
ms_client_id: str = ""
ms_client_secret: str = ""
# ── Mémoire de l'agent (v7.54.0) ──
# État initial du toggle « Mémoire » à la création d'une conversation ;
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
agent_memory_default: bool = True
# ── Web tools de l'agent (web_search / fetch_url / search_code) ──
# `web_search_provider` : « exa » (recherche sémantique + snippets, clé
# requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé).
+11 -5
View File
@@ -5,7 +5,7 @@ import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as _StarHTTPException
@@ -62,6 +62,7 @@ from app.routers.web_clipper import router as web_clipper_router
from app.routers.webauthn import router as webauthn_router
from app.routers.wiki import router as wiki_router
from app.routers.workers import router as workers_router
from app.services import plugins as plugins_service
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -185,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.49.2",
version="7.59.2",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -235,7 +236,9 @@ app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
# Plugin « automations » OFF → chaque route de ce router renvoie 404
app.include_router(automations_router,
dependencies=[Depends(plugins_service.plugin_required("automations"))])
app.include_router(collaboration_router)
app.include_router(emoji_router)
app.include_router(realtime_router)
@@ -247,8 +250,11 @@ app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
app.include_router(permissions_router)
app.include_router(web_clipper_api_router)
app.include_router(web_clipper_router)
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
app.include_router(web_clipper_api_router,
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
app.include_router(web_clipper_router,
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
app.include_router(api_v2_router)
app.include_router(api_v2_agent_router)
app.include_router(sites_router)
+115
View File
@@ -1584,3 +1584,118 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_collections_task "
"ON collections(is_task, workspace_id)"
)
@register(35, "v7.58.0: registre des plugins (on/off à effet réel)")
def _migration_plugins(conn: sqlite3.Connection) -> None:
"""Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels)."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS plugins (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1
)"""
)
conn.executemany(
"INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)",
[
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
],
)
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
serveurs MCP (kind='mcp', cache d'outils) réutilisent la même table."""
cols = columns(conn, "agent_connectors")
if "kind" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'")
if "auth" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN auth TEXT NOT NULL DEFAULT 'bearer'")
if "tools_json" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN tools_json TEXT NOT NULL DEFAULT '[]'")
@register(33, "v7.56.0: tokens OAuth des connecteurs (Google / M365)")
def _migration_connector_tokens(conn: sqlite3.Connection) -> None:
"""Tokens OAuth par (kind, utilisateur), chiffrés Fernet côté service."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS connector_tokens (
kind TEXT NOT NULL,
user_id INTEGER NOT NULL,
tokens_enc TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (kind, user_id)
)"""
)
@register(32, "v7.55.0: connecteurs de l'agent (socle)")
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
Colonne ``url`` plate (pas de ``config_json``) : les scopes/OAuth des
phases 6-7 ajouteront leur propre colonne le moment venu.
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS agent_connectors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
secret_encrypted TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'unknown',
detail TEXT NOT NULL DEFAULT '',
created_by INTEGER
)"""
)
@register(31, "v7.54.0: agent memory (mémoire de l'agent)")
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
"""Toggle par conversation + table des résumés mémorisés.
Une ligne résumé par conversation (upsert) — voir `app/services/agent_memory.py`.
"""
if "memory_enabled" not in columns(conn, "agent_conversations"):
conn.execute(
"ALTER TABLE agent_conversations "
"ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1"
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS agent_memory (
conversation_id INTEGER PRIMARY KEY
REFERENCES agent_conversations(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'summary',
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
"""mistral-large-latest / pixtral-large-latest renvoient 403
« tier_not_allowed » sur les plans d'abonnement basiques : la clé est
valide mais le test de connexion et l'agent échouaient. Reset du
default_model stocké → vide = nouveau défaut du provider (small-latest,
servi par tous les plans).
"""
blocked = ("mistral-large-latest", "pixtral-large-latest")
conn.execute(
"UPDATE user_llm_keys SET default_model='' "
"WHERE provider='mistral' AND default_model IN (?,?)",
blocked,
)
conn.execute(
"UPDATE llm_config SET model='mistral-small-latest' "
"WHERE provider='mistral' AND model IN (?,?)",
blocked,
)
+237 -6
View File
@@ -7,15 +7,17 @@ from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import UTC
from urllib.parse import urlparse
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from app.auth.session import get_current_user
from app.config import settings
from app.db import get_conn
from app.services import skill_gallery
from app.services import connectors, oauth_connectors, plugins, skill_gallery
from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import (
@@ -199,15 +201,17 @@ def create_conversation(request: Request, body: dict = Body(default={})):
ws = _workspace_id(request)
with get_conn() as conn:
agent = _default_agent(conn, user_id)
mem_default = 1 if settings.agent_memory_default else 0
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
VALUES (?,?,?,?,?,?)""",
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model, memory_enabled)
VALUES (?,?,?,?,?,?,?)""",
(agent["id"], user_id, body.get("title", "New conversation"),
json.dumps({"workspace_id": ws}),
body.get("provider", ""), body.get("model", "")),
body.get("provider", ""), body.get("model", ""), mem_default),
)
conn.commit()
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"),
"status": "created", "memory_enabled": mem_default}
@router.get("/conversations/{conversation_id}")
@@ -249,6 +253,10 @@ def patch_conversation(request: Request, conversation_id: int, body: dict = Body
if body.get(col) is not None:
sets.append(f"{col}=?")
params.append(str(body[col]))
# v7.54.0 — toggle « Mémoire » de la conversation (0/1).
if body.get("memory_enabled") is not None:
sets.append("memory_enabled=?")
params.append(1 if body["memory_enabled"] else 0)
params.append(conversation_id)
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
@@ -498,6 +506,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.patch("/skills/{skill_id}")
def update_skill(request: Request, skill_id: int, body: dict = Body(default={})):
"""Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).
Seuls les champs présents dans ``body`` sont mis à jour ; la liste des
colonnes est figée ici (jamais interpolée depuis la requête).
"""
_current_user_id(request)
fields: dict = {}
if "name" in body:
name = str(body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name est requis")
fields["name"] = name
if "description" in body:
fields["description"] = str(body.get("description") or "")
if "prompt_template" in body:
fields["prompt_template"] = str(body.get("prompt_template") or "")
if "allowed_tools" in body:
fields["allowed_tools_json"] = json.dumps(body.get("allowed_tools") or [])
if not fields:
raise HTTPException(status_code=400, detail="Aucun champ à mettre à jour")
cols = ", ".join(f"{k}=?" for k in fields)
with get_conn() as conn:
if not conn.execute("SELECT id FROM agent_skills WHERE id=?", (skill_id,)).fetchone():
raise HTTPException(status_code=404, detail="Skill introuvable")
try:
conn.execute(
f"UPDATE agent_skills SET {cols} WHERE id=?",
(*fields.values(), skill_id),
)
conn.commit()
except Exception as exc: # noqa: BLE001 — contrainte UNIQUE(name)
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
return {"id": skill_id, "status": "updated", "fields": sorted(fields)}
@router.post("/skills/{skill_id}/apply")
def apply_skill(request: Request, skill_id: int):
"""Create a conversation pre-loaded with a skill, ready to run."""
@@ -590,6 +635,192 @@ def delete_skill(request: Request, skill_id: int):
return {"id": skill_id, "status": "deleted"}
# ── Connecteurs (v7.55.0) ──
# ponytail : catalogue partagé au même titre que les skills (`list_skills`
# n'a pas non plus de filtre par créateur) — la clé n'est jamais renvoyée.
@router.get("/connectors")
def list_connectors_route(request: Request):
"""Catalogue : natifs (statut dérivé de la config) + personnels."""
user_id = _current_user_id(request)
return {"connectors": connectors.list_connectors(user_id)}
@router.get("/plugins")
def list_plugins_route(request: Request):
"""Catalogue des plugins de l'instance (menu + → « Add plugins »)."""
_current_user_id(request)
return {"plugins": plugins.list_plugins()}
@router.patch("/plugins/{slug}")
def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})):
"""Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau."""
_current_user_id(request)
try:
return plugins.set_enabled(slug, bool(body.get("enabled")))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/connectors")
def create_connectors_route(request: Request, body: dict = Body(default={})):
"""Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF)."""
_current_user_id(request)
try:
return connectors.create_connector(
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
kind=str(body.get("kind") or "custom"),
auth=str(body.get("auth") or "bearer"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/connectors/{connector_id}")
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
_current_user_id(request)
try:
row = connectors.update_connector(
connector_id,
name=body.get("name"),
enabled=body.get("enabled"),
secret=body.get("secret"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if row is None:
raise HTTPException(status_code=404, detail="Connecteur introuvable")
return row
@router.delete("/connectors/{connector_id}")
def delete_connectors_route(request: Request, connector_id: int):
_current_user_id(request)
if not connectors.delete_connector(connector_id):
raise HTTPException(status_code=404, detail="Connecteur introuvable")
return {"id": connector_id, "status": "deleted"}
@router.post("/connectors/probe")
async def probe_connectors_route(request: Request, body: dict = Body(default={})):
"""Teste un connecteur (id, nom ou kind natif) et persiste le résultat."""
target = str(body.get("connector") or "").strip()
if not target:
raise HTTPException(status_code=400, detail="connector est requis")
user_id = _current_user_id(request)
try:
return await connectors.probe(target, user_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# ── Connecteurs OAuth : Google / Microsoft 365 (v7.56.0) ──
# Flow : POST authorize (cookies state/verifier/next) → fournisseur →
# GET callback (échange du code, tokens chiffrés) → redirection vers la page
# d'origine. Le GET est en SAFE_METHODS : pas de CSRF (comme /auth/callback).
def _oauth_kind(kind: str) -> str:
if kind not in oauth_connectors.OAUTH_KINDS:
raise HTTPException(status_code=404, detail="Connecteur OAuth inconnu")
return kind
def _oauth_next(request: Request, kind: str, key: str, default: str = "/") -> str:
"""Chemin de retour same-origin (cookie `key` pour le callback)."""
raw = request.cookies.get(key, "") or default
path = urlparse(raw).path if raw.startswith("http") else raw
if not path.startswith("/") or path.startswith("//"):
return default
return path
@router.get("/connectors/oauth/{kind}/status")
def connector_oauth_status(request: Request, kind: str):
"""État du connecteur OAuth pour l'utilisateur courant."""
_oauth_kind(kind)
user_id = _current_user_id(request)
try:
oauth_connectors._client(kind)
configured, detail = True, ""
except ValueError as exc:
configured, detail = False, str(exc)
tok = oauth_connectors.tokens(kind, user_id)
return {
"kind": kind, "configured": configured, "configured_detail": detail,
"connected": bool(tok.get("access_token")), "scope": tok.get("scope", ""),
"expires_at": tok.get("expires_at", 0),
}
@router.post("/connectors/oauth/{kind}/authorize")
def connector_oauth_authorize(request: Request, kind: str):
"""URL d'autorisation (PKCE) + cookies d'état éphémères (10 min)."""
_oauth_kind(kind)
_current_user_id(request)
try:
flow = oauth_connectors.begin(kind)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
referer = request.headers.get("referer") or ""
next_path = urlparse(referer).path if referer else "/"
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/"
secure = request.url.scheme == "https"
resp = JSONResponse({"url": flow["url"], "kind": kind})
for key, value in (
(f"fd_oauth_state_{kind}", flow["state"]),
(f"fd_oauth_verifier_{kind}", flow["verifier"]),
(f"fd_oauth_next_{kind}", next_path),
):
resp.set_cookie(key, value, max_age=600, httponly=True, samesite="lax", secure=secure)
return resp
@router.get("/connectors/oauth/{kind}/callback")
async def connector_oauth_callback(
request: Request, kind: str, code: str = "", state: str = "", error: str = ""
):
"""Reçoit le code du fournisseur, échange, stocke, renvoie sur la page."""
_oauth_kind(kind)
next_path = _oauth_next(request, kind, f"fd_oauth_next_{kind}")
expected = request.cookies.get(f"fd_oauth_state_{kind}", "")
verifier = request.cookies.get(f"fd_oauth_verifier_{kind}", "")
def _back(flag: str) -> RedirectResponse:
sep = "&" if "?" in next_path else "?"
resp = RedirectResponse(f"{next_path}{sep}{flag}", status_code=302)
for key in (f"fd_oauth_state_{kind}", f"fd_oauth_verifier_{kind}",
f"fd_oauth_next_{kind}"):
resp.delete_cookie(key, samesite="lax")
return resp
if error:
return _back("oauth_error=" + error[:80])
if not code or not expected or not secrets.compare_digest(expected, state):
return _back("oauth_error=state")
user = get_current_user(request)
if not user or not user.get("id"):
return _back("oauth_error=session")
try:
tokens = await oauth_connectors.complete(kind, code, verifier)
except ValueError as exc:
return _back("oauth_error=" + str(exc)[:80].replace(" ", "+"))
oauth_connectors.save(kind, int(user["id"]), tokens)
logger.info("Connector OAuth connected: %s (user #%s)", kind, user["id"])
return _back("oauth=connected")
@router.post("/connectors/oauth/{kind}/disconnect")
def connector_oauth_disconnect(request: Request, kind: str):
_oauth_kind(kind)
user_id = _current_user_id(request)
removed = oauth_connectors.clear(kind, user_id)
return {"kind": kind, "status": "disconnected" if removed else "not-connected"}
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
+34
View File
@@ -226,4 +226,38 @@ def use_page_template(request: Request, template_id: int, body: dict = Body(defa
return {"status": "ok", "id": page_id, "title": title or name}
@router.get("/api/page-templates/{template_id}/blocks")
def page_template_blocks(request: Request, template_id: int, key: str = ""):
"""v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.
Builtin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).
Canevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le
front appelle ``ensureBlockIds()`` (déjà global côté éditeur).
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if template_id == 0:
from app.services.block_templates import blocks_json_for
raw = blocks_json_for(key or "empty")
if raw is None:
raise HTTPException(404, "Unknown built-in template")
else:
uid = (user or {}).get("id")
with get_conn() as conn:
row = conn.execute(
"SELECT blocks_json FROM page_global_templates "
"WHERE id=? AND (created_by IS NULL OR created_by=?)",
(template_id, uid),
).fetchone()
if not row:
raise HTTPException(404, "Template not found")
raw = row["blocks_json"]
try:
blocks = json.loads(raw)
except (json.JSONDecodeError, TypeError) as exc:
raise HTTPException(500, "Template content corrupted") from exc
if not isinstance(blocks, list):
raise HTTPException(500, "Template content corrupted")
return {"blocks": blocks}
# ── Core helpers ──
+6 -203
View File
@@ -217,8 +217,10 @@ def view_page_root(request: Request, page_id: int):
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
).render(),
)
# Select template: collection pages use database table view
if page.get("content_format") == "collection" and not embed:
# Select template: collection pages use database table view (aussi en
# mode embed : `page_editor_collection.html` + body.embed-mode = le
# tableau SANS sidebar ni barre — le peek d'une base reste le tableau).
if page.get("content_format") == "collection":
template = env.get_template("page_editor_collection.html")
else:
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
@@ -249,209 +251,10 @@ def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
def help_page(request: Request):
"""Comprehensive help & documentation page."""
"""Help & documentation page — settings-style panel with side navigation."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
)
return HTMLResponse(block_tpl.render(
**sidebar,
request=request,
page_title="Help",
title_prefix="Help",
page_icon="❓",
content_html="""<style>
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
.help-hero{text-align:center;margin-bottom:48px;}
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
.help-card:hover{border-color:rgba(255,255,255,.12);}
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
.help-card .icon{font-size:20px;}
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
.help-card li::before{content:'• ';color:var(--accent);}
.help-section{margin-bottom:48px;}
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
.help-shortcut-row:hover{background:var(--bg-hover);}
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="help-page">
<div class="help-hero">
<h1>❓ FlowDeck Help</h1>
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
</div>
<div class="help-grid">
<div class="help-card">
<h3><span class="icon">🚀</span>Getting Started</h3>
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
<ul>
<li>Create a workspace from the <b>Workspaces</b> page</li>
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📝</span>Pages & Editor</h3>
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
<ul>
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
<li>Drag & drop pages in the sidebar to reorganize</li>
<li>Right-click for context menu (duplicate, rename, delete)</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
<ul>
<li><span class="help-badge local">Local</span> Files stored on your server</li>
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">🦎</span>Gitea Integration</h3>
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
<ul>
<li>Go to <b>Settings → Integrations</b> to connect</li>
<li>Browse repo file trees in the sidebar</li>
<li>Create & edit files with commit messages</li>
<li>Sync labels as tags</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
<p>Share pages with collaborators or publish them to the web.</p>
<ul>
<li>Click <b>Share</b> in the page editor top-right</li>
<li>Share with specific users or get a public link</li>
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
<p>Find all your content in one place with powerful filtering.</p>
<ul>
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📶</span>Offline & PWA</h3>
<p>Install FlowDeck as an app and keep working without a connection.</p>
<ul>
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
<li>Edits made offline are queued locally and synced automatically</li>
<li>A <b>⟳</b> marker shows pages with pending changes</li>
</ul>
</div>
</div>
<div class="help-section">
<h2>⌨️ Keyboard Shortcuts</h2>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
</div>
<div class="help-section">
<h2>🔐 Authentication</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck supports three authentication methods:<br>
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
</p>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
<b>Settings → Admin → SSO / Enterprise</b> (login history).
</p>
</div>
<div class="help-section">
<h2>📶 Offline mode (PWA)</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
edits are saved locally, then synchronised when the connection returns.<br><br>
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
<b>Offline editing:</b> while offline, the editor stores changes in the browser
(IndexedDB) and shows an offline banner with the number of pending changes. A
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
A spinner badge appears while syncing, followed by a confirmation toast.<br>
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
page. If a page with the same title already exists, the offline copy is renamed
<i>“Title (copie offline)”</i>.
</p>
</div>
<div class="help-section">
<h2>🔌 API publique v2</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
<b>Auth:</b> create a token in Settings → API tokens, then send it as
<code>Authorization: Bearer &lt;token&gt;</code>. Tokens carry scopes
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;offset=</code> +
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
</p>
</div>
<div class="help-section">
<h2>💡 Tips</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
</p>
</div>
</div>"""
))
return ENV.get_template("help.html").render(**sidebar, request=request)
@router.get("/accounts/settings", response_class=HTMLResponse)
+8
View File
@@ -18,6 +18,7 @@ import re
from app.config import settings
from app.db import get_conn
from app.services import agent_memory
from app.services.agent_policies import check_tool, get_policy
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
@@ -156,6 +157,11 @@ class AgentEngine:
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
system = self._build_system_prompt(agent, skills)
context = self.ctx.build(mentions=mentions, files=files)
# v7.54.0 — mémoire de la conversation (toggle) : le moteur n'envoie
# jamais l'historique, sans elle chaque run repart de zéro.
memory = agent_memory.context_for(conversation_id)
if memory:
context = (context + "\n\n" + memory) if context else memory
if extra_context and extra_context.strip():
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
@@ -304,6 +310,8 @@ class AgentEngine:
self._persist_message(conversation_id, "assistant", final_text,
model=used_model, tokens=self._tokens)
await self._autotitle(conversation_id, objective, final_text)
# v7.54.0 — memorise l'echange (no-op si le toggle memoire est OFF).
agent_memory.remember(conversation_id, objective, final_text)
# v6.4.0: emit agent.run.finished (outbound webhooks only).
await _fire_agent_webhook("agent.run.finished", {
"conversation_id": conversation_id,
+89
View File
@@ -0,0 +1,89 @@
"""Mémoire de l'agent (v7.54.0).
Une ligne résumé **par conversation**, mise à jour à chaque run et ré-injectée
au contexte du run suivant — le moteur n'envoie jamais l'historique des messages
(`AgentEngine.run()` ne construit que system + objectif courant), donc sans
mémoire chaque run repart de zéro.
Toggle : colonne `agent_conversations.memory_enabled` — OFF = aucune lecture ni
écriture (contexte strict du run courant).
ponytail : mémoire par conversation seulement. Si besoin de notes partagées
entre conversations, ajouter des lignes avec ``conversation_id NULL`` +
``workspace_id`` et élargir la lecture dans ``context_for()``.
"""
from __future__ import annotations
import logging
import sqlite3
from app.db import get_conn
logger = logging.getLogger(__name__)
INJECT_BUDGET = 4000 # caractères de mémoire max injectés dans un prompt
NOTE_LINES = 20 # échanges max conservés
NOTE_OBJECTIVE = 180 # troncature de l'objectif
NOTE_ANSWER = 700 # troncature de la réponse
def _enabled(conn: sqlite3.Connection, conversation_id: int) -> bool:
row = conn.execute(
"SELECT memory_enabled FROM agent_conversations WHERE id=?",
(conversation_id,),
).fetchone()
if row is None:
return False
return bool(row["memory_enabled"])
def _one_line(text: str, limit: int) -> str:
return " ".join((text or "").split())[:limit]
def context_for(conversation_id: int) -> str:
"""Bloc « mémoire » à préfixer au contexte, ou ``''`` (toggle OFF / vide)."""
with get_conn() as conn:
if not _enabled(conn, conversation_id):
return ""
row = conn.execute(
"SELECT content FROM agent_memory WHERE conversation_id=?",
(conversation_id,),
).fetchone()
content = ((row["content"] if row else "") or "").strip()
if not content:
return ""
if len(content) > INJECT_BUDGET:
content = "…(début de mémoire tronqué)\n" + content[-INJECT_BUDGET:]
return "## Mémoire de la conversation (échanges précédents)\n" + content
def remember(conversation_id: int, objective: str, final_text: str) -> None:
"""Ajoute un échange à la mémoire — no-op si le toggle est OFF.
Ne doit **jamais** faire échouer un run : toute erreur est journalisée.
"""
note = f"- **{_one_line(objective, NOTE_OBJECTIVE)}** → {_one_line(final_text, NOTE_ANSWER)}"
try:
with get_conn() as conn:
if not _enabled(conn, conversation_id):
return
row = conn.execute(
"SELECT content FROM agent_memory WHERE conversation_id=?",
(conversation_id,),
).fetchone()
lines = [ln for ln in ((row["content"] if row else "") or "").split("\n") if ln.strip()]
lines.append(note)
if len(lines) > NOTE_LINES:
lines = lines[-NOTE_LINES:]
conn.execute(
"INSERT INTO agent_memory (conversation_id, content, updated_at) "
"VALUES (?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(conversation_id) DO UPDATE SET "
"content=excluded.content, updated_at=CURRENT_TIMESTAMP",
(conversation_id, "\n".join(lines)),
)
conn.commit()
except Exception: # noqa: BLE001 — la mémoire ne casse jamais un run
logger.exception("Agent memory save failed for conversation #%s", conversation_id)
+4 -1
View File
@@ -27,7 +27,7 @@ import time
from datetime import UTC, datetime, timedelta
from app.db import get_conn
from app.services import notifications
from app.services import notifications, plugins
from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
@@ -479,6 +479,9 @@ async def automation_scheduler():
"""Background loop: fire due cron automations (checked every 60s)."""
while True:
try:
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
await asyncio.sleep(60)
continue
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
+28
View File
@@ -86,6 +86,34 @@ BUILTIN_TEMPLATES: dict[str, dict] = {
_b("bulleted_list"),
],
},
"design_system": {
"name": "Design System",
"icon": "🎨",
"description": "Tokens, composants et grille — canevas de référence UI.",
"blocks": [
_b("heading_1", "Design System"),
_b("callout",
"Tokens : couleur, espacement, typographie, rayon, ombres "
"(static/css/design-tokens.css).",
icon="🎨"),
_b("table_of_contents"),
_b("heading_2", "Composants"),
_b("toggle", "Boutons & actions", expanded=False,
children=[_b("paragraph", "Primaire · secondaire · danger — états idle, hover, focus, disabled.")]),
_b("toggle", "Cartes & panneaux", expanded=False,
children=[_b("paragraph", "Élévation, rayon, bordure, padding, zones de clic.")]),
_b("toggle", "Formulaires", expanded=False,
children=[_b("paragraph", "Champs, labels, hints, erreurs, focus visible.")]),
_b("heading_2", "Grille & espacement"),
_b("bulleted_list", "Base 4 px — pas d'espacement hors échelle."),
_b("bulleted_list", "Colonnes : 12 / 8 / 4 selon le point de rupture."),
_b("heading_2", "Revue UI"),
_b("to_do", "Contraste AA vérifié", checked=False),
_b("to_do", "États hover / focus / disabled", checked=False),
_b("to_do", "Responsive mobile", checked=False),
_b("quote", "Une décision de design vaut mieux qu'une justification après coup."),
],
},
}
+336
View File
@@ -0,0 +1,336 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
l'URL via ``{secret}``) | ``mcp`` (serveur Model Context Protocol, cache
d'outils dans ``tools_json``).
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
"""
from __future__ import annotations
import json
import logging
from app.config import settings
from app.db import get_conn
from app.services import oauth_connectors
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp")
AUTH_SCHEMES = ("bearer", "bot", "none")
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
# par l'utilisateur — jamais codé en dur ici).
PRESETS = {
"discord": {"url": "https://discord.com/api/v10", "auth": "bot",
"hint": "Bot Discord Developers → Token"},
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
"mcp": {"url": "https://", "auth": "bearer",
"hint": "URL du endpoint MCP (streamable HTTP)"},
}
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
if kind in OAUTH_KINDS:
try:
oauth_connectors._client(kind) # lève si client_id/secret absents
except ValueError as exc:
return ("missing", str(exc))
tok = oauth_connectors.tokens(kind, user_id)
if tok.get("access_token"):
scope = (tok.get("scope") or "").split()
return ("ok", f"connecté · {len(scope)} scope(s)")
return ("missing", "non connecté — lancer la connexion OAuth")
if kind == "gitea":
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
if kind == "github":
if settings.github_token:
return ("ok", "PAT configuré (30 req/min)")
return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)")
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
def _row(r) -> dict:
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais)."""
try:
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
except (ValueError, TypeError):
tools = []
return {
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
"status": r["status"], "detail": r["detail"] or "",
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
"tools_count": len(tools),
}
def list_connectors(user_id: int | None = None) -> list[dict]:
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
out: list[dict] = []
for kind in NATIVE_KINDS:
status, detail = native_state(kind, user_id)
out.append({
"id": None, "builtin": True, "kind": kind,
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
else kind.capitalize()),
"url": "", "enabled": True, "status": status, "detail": detail,
"has_secret": False, "oauth": kind in OAUTH_KINDS,
})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
).fetchall()
return out + [_row(r) for r in rows]
def get(connector_id: int) -> dict | None:
with get_conn() as conn:
r = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
return _row(r) if r else None
# ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "", *,
kind: str = "custom", auth: str = "bearer") -> dict:
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
from app.services.importers.url_fetch import _validate_url
name = (name or "").strip()
if not name:
raise ValueError("name est requis")
if kind not in CUSTOM_KINDS:
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
if auth not in AUTH_SCHEMES:
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
url = (url or "").strip()
if "{secret}" in url and not (secret or "").strip():
raise ValueError("clé requise : l'URL contient {secret}")
url = _validate_url(url) # lève ValueError si hôte interne
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
)
conn.commit()
cid = cur.lastrowid
return get(cid)
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None:
row = get(connector_id)
if row is None:
return None
sets, params = [], []
if name is not None:
name = str(name).strip()
if not name:
raise ValueError("name est requis")
sets.append("name=?")
params.append(name)
if enabled is not None:
sets.append("enabled=?")
params.append(1 if enabled else 0)
if secret is not None:
sets.append("secret_encrypted=?")
params.append(encrypt_secret(str(secret)))
if sets:
with get_conn() as conn:
params.append(connector_id)
conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
return get(connector_id)
def delete_connector(connector_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,))
conn.commit()
return cur.rowcount > 0
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
def _headers(connector_id: int | None = None) -> dict:
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
"""
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret, auth = "", "bearer"
if connector_id:
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
if r:
secret = decrypt_secret(r["secret_encrypted"] or "")
auth = r["auth"] or "bearer"
if secret and auth == "bot":
headers["Authorization"] = f"Bot {secret}"
elif secret and auth != "none":
headers["Authorization"] = f"Bearer {secret}"
headers["X-Api-Key"] = secret
return headers
def _with_secret(url: str, connector_id: int) -> str:
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
if "{secret}" not in url:
return url
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
if not secret:
raise ValueError("clé manquante pour une URL contenant {secret}")
return url.replace("{secret}", secret)
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
"""GET gardé SSRF (validation + re-vérification après redirection).
Point d'injection des tests : on monkeypatche ``connectors._get``.
"""
from app.services.http_client import shared_client
from app.services.importers.url_fetch import _is_public_host, _validate_url
safe = _validate_url(url)
async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client:
resp = await client.get(safe)
if resp.url.host and not _is_public_host(resp.url.host):
raise ValueError("Redirection vers un hôte non autorisé")
return resp.status_code, (resp.text or "")[:FETCH_LIMIT]
def _resolve(connector: str) -> tuple[str, str | int]:
"""« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind)."""
token = str(connector or "").strip()
if not token:
raise ValueError("connector est requis")
if token.isdigit():
row = get(int(token))
if row is None:
raise ValueError(f"Connecteur inconnu: {token}")
return (row["kind"], row["id"])
low = token.lower()
if low in NATIVE_KINDS:
return (low, low)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
).fetchone()
if row:
return (get(row["id"])["kind"], row["id"])
raise ValueError(f"Connecteur inconnu: {token}")
async def probe(connector: str, user_id: int | None = None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
kind, ref = _resolve(connector)
try:
if kind == "mcp":
from app.services import mcp_client
tools = await mcp_client.initialize_and_list(int(ref))
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
elif kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id,
oauth_connectors.PROVIDERS[kind]["probe_path"])
status, detail = res["status"], res["text"][:200]
elif kind == "gitea":
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
{"Authorization": f"token {settings.gitea_token}"})
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
elif kind == "github":
code, _ = await _get("https://api.github.com/rate_limit", _gh_headers())
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
elif kind == "web":
status, detail = "ok", native_state("web")[1]
else:
row = get(int(ref))
url = _with_secret(row["url"], int(ref))
code, _ = await _get(url, _headers(int(ref)))
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
logger.info("Connector probe failed (%s): %s", connector, exc)
status, detail = "error", str(exc)[:200]
if kind not in NATIVE_KINDS:
with get_conn() as conn:
conn.execute(
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
(status, detail, int(ref)),
)
conn.commit()
return {"connector": connector, "status": status, "detail": detail}
def _gh_headers() -> dict:
headers = {"User-Agent": "FlowDeck-Connectors/1.0",
"Accept": "application/vnd.github+json"}
if settings.github_token:
headers["Authorization"] = f"Bearer {settings.github_token}"
return headers
async def connector_fetch(connector: str, path: str = "", query: str = "",
user_id: int | None = None) -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector)
path = (path or "").strip()
if kind in OAUTH_KINDS:
return await oauth_connectors.api_get(kind, user_id, path)
if kind == "mcp":
from app.services import mcp_client
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
if kind == "gitea":
base = settings.gitea_url.rstrip("/")
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"})
elif kind == "github":
url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit"
code, text = await _get(url, _gh_headers())
elif kind == "web":
from app.services.web_search import search_web
if not (query or "").strip():
return {"status": "error", "text": "query est requis pour le connecteur web"}
results, provider = await search_web(query.strip(), 5)
text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}"
for r in results) or "Aucun résultat."
return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]}
else:
row = get(int(ref))
if row is None:
return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"}
url = _with_secret(row["url"], int(ref)).rstrip("/")
if path:
url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref)))
if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:FETCH_LIMIT]}
+34 -1
View File
@@ -118,7 +118,7 @@ class ContextBuilder:
return "\n".join(lines)
def _mentions_context(self, mentions: list[str]) -> str:
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
"""Resolve @document:x / @collection:x / @page:y / @folder:d / @repo:o/r mentions.
Mentions bring the *actual content* of the referenced object into the
context so the LLM can summarise / rewrite / analyse it directly without
@@ -135,6 +135,8 @@ class ContextBuilder:
elif m.startswith("page:"):
pid = m.split(":", 1)[1]
lines.append(self._single_page(pid))
elif m.startswith("folder:"):
lines.append(self._single_folder(m.split(":", 1)[1]))
elif m.startswith("repo:"):
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
elif m == "ws":
@@ -209,6 +211,37 @@ class ContextBuilder:
return f"{head}:\n{body[:9000]}"
return head
def _single_folder(self, fid: str) -> str:
"""Folder (répertoire) mention : titre du dossier + ses documents directs.
ponytail : enfants directs seulement, budget ~12k caractères —
ajouter une profondeur récursive si les arbres profonds deviennent réels.
"""
with get_conn() as conn:
row = conn.execute(
"SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL",
(fid,),
).fetchone()
if not row:
return f"- dossier #{fid}: introuvable"
kids = conn.execute(
"SELECT id FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(fid,),
).fetchall()
title = row["title"] or "Sans titre"
head = f"- dossier #{row['id']} **{title}** ({len(kids)} élément(s))"
out = [head]
used = len(head)
for k in kids:
part = self._single_document(str(k["id"]))
if used + len(part) + 1 > 12000:
out.append("- … (contenu du dossier tronqué)")
break
used += len(part) + 1
out.append(part)
return "\n".join(out)
def _single_collection(self, cid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
+8 -3
View File
@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
PROVIDERS = {
"openai": ("https://api.openai.com/v1", "gpt-4o"),
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
# mistral-small est servi par tous les plans d'abonnement ;
# mistral-large → 403 tier_not_allowed sur les plans basiques.
"mistral": ("https://api.mistral.ai/v1", "mistral-small-latest"),
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
@@ -94,8 +96,11 @@ PROVIDER_LABELS: dict[str, str] = {
PROVIDER_MODELS: dict[str, list[str]] = {
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
# Ordre = ordre de sélection du test de connexion : les modèles servis par
# tous les plans d'abord (large/pixtral-large = 403 tier_not_allowed en plan
# basique), les modèles à plan élevé en fin de liste.
"mistral": ["mistral-small-latest", "mistral-medium-latest", "open-mistral-nemo",
"codestral-latest", "mistral-large-latest", "pixtral-large-latest"],
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
+4 -1
View File
@@ -22,7 +22,10 @@ from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
# before being exposed as "usable models". NVIDIA exposes all of its catalog
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
# chat completions — the exact failure the user hit.
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
# Mistral : le /models liste des modèles hors du plan d'abonnement du compte
# (403 « tier_not_allowed » ex. mistral-large sur plan basique) — la sonde
# chat ne garde que ceux réellement servis par la CLÉ de l'utilisateur.
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia", "mistral"})
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
_NON_CHAT_MARKERS = (
+181
View File
@@ -0,0 +1,181 @@
"""Client MCP (Model Context Protocol) — streamable HTTP, v7.57.0.
JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list``
→ ``tools/call``. Les outils sont **cachés** dans
``agent_connectors.tools_json`` (kind ``mcp``) puis exposés au LLM par
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
sans état en mémoire.
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
réponse JSON directe, soit la première ligne ``data:`` d'une
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
répondent en JSON chez la grande majorité des serveurs).
"""
from __future__ import annotations
import json
import logging
import re
from app.db import get_conn
from app.services.connectors import _headers, _with_secret, get
logger = logging.getLogger(__name__)
# Version la plus répandue côté serveurs (un serveur plus récent rétrograde).
PROTOCOL_VERSION = "2024-11-05"
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
def tool_name(server_name: str, tool_name: str) -> str:
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
def slug(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_")
joined = f"mcp_{slug(server_name)}_{slug(tool_name)}"
return re.sub(r"_{2,}", "_", joined)
def parse_body(content_type: str, body: str, status: int) -> dict:
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
text = body or ""
if "text/event-stream" in (content_type or ""):
for line in text.splitlines():
if line.startswith("data:"):
text = line[5:].strip()
break
try:
data = json.loads(text)
except ValueError as exc:
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
if isinstance(data, dict) and data.get("error"):
err = data["error"] if isinstance(data["error"], dict) else {}
raise ValueError(f"MCP {err.get('code', '?')} : {err.get('message', 'erreur')}")
return data if isinstance(data, dict) else {}
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
from app.services.http_client import shared_client
from app.services.importers.url_fetch import _validate_url
safe = _validate_url(url)
hdrs = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**(headers or {})}
async with shared_client(timeout=15, headers=hdrs) as client:
resp = await client.post(safe, json=payload)
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
resp.status_code)
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
"""Notification JSON-RPC (202 sans corps) — les erreurs sont ignorées."""
try:
await _rpc(url, payload, headers)
except Exception as exc: # noqa: BLE001
logger.debug("MCP notification ignorée: %s", exc)
def _server(connector_id: int) -> dict:
row = get(connector_id)
if row is None:
raise ValueError("Serveur MCP introuvable")
if row["kind"] != "mcp":
raise ValueError("Ce connecteur n'est pas un serveur MCP")
if not row["enabled"]:
raise ValueError("Serveur MCP désactivé")
return {"id": row["id"], "name": row["name"], "url": row["url"],
"headers": _headers(connector_id)}
def _normalize(server: dict, raw: list) -> list[dict]:
out = []
for t in raw if isinstance(raw, list) else []:
if not isinstance(t, dict) or not t.get("name"):
continue
params = t.get("inputSchema")
if not isinstance(params, dict):
params = {"type": "object", "properties": {}}
out.append({
"name": tool_name(server["name"], t["name"]),
"original": str(t["name"]),
"description": str(t.get("description") or "Outil MCP"),
"parameters": params,
})
return out
def _save_tools(connector_id: int, tools: list[dict]) -> None:
with get_conn() as conn:
conn.execute("UPDATE agent_connectors SET tools_json=? WHERE id=?",
(json.dumps(tools), connector_id))
conn.commit()
async def initialize_and_list(connector_id: int) -> list[dict]:
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
await _rpc(url, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": CLIENT_INFO},
}, srv["headers"])
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
srv["headers"])
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
"params": {}}, srv["headers"])
result = data.get("result") if isinstance(data.get("result"), dict) else {}
tools = _normalize(srv, result.get("tools") or [])
_save_tools(connector_id, tools)
return tools
def cached_tools() -> list[dict]:
"""Outils MCP des serveurs **activés** (cache en base) — lu par ToolRegistry."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, tools_json FROM agent_connectors WHERE kind='mcp' AND enabled=1"
).fetchall()
out: list[dict] = []
for r in rows:
try:
tools = json.loads(r["tools_json"] or "[]")
except (ValueError, TypeError):
tools = []
for t in tools if isinstance(tools, list) else []:
if isinstance(t, dict) and t.get("name"):
out.append({**t, "connector_id": r["id"]})
return out
def tools_text(connector_id: int) -> str:
"""Liste lisible des outils cachés (utilisée par ``connector_fetch``)."""
tools = [t for t in cached_tools() if t["connector_id"] == connector_id]
if not tools:
return "Aucun outil en cache — lancez « Tester le connecteur » (tools/list)."
return "\n".join(f"- {t['name']} : {t.get('description', '')}" for t in tools)
def _content_text(result: dict) -> str:
parts = []
for item in result.get("content") or []:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(str(item.get("text") or ""))
else:
parts.append(json.dumps(item, ensure_ascii=False)[:2000])
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
data = await _rpc(url, {
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": tool, "arguments": arguments or {}},
}, srv["headers"])
result = data.get("result") if isinstance(data.get("result"), dict) else {}
text = _content_text(result)[:20000]
return {"status": "error" if result.get("isError") else "ok", "text": text}
+255
View File
@@ -0,0 +1,255 @@
"""Connecteurs OAuth Google / Microsoft 365 (v7.56.0 — phase 6).
Flow : ``begin()`` (URL d'autorisation PKCE S256 + state) → callback
``complete()`` (échange du code) → tokens chiffrés Fernet → ``api_get()`` avec
refresh automatique.
Réutilise : `_encrypt_tokens` / `_decrypt_tokens` (`calendar_sync`, déjà Fernet)
et `shared_client` (A42). Les scopes sont **figés en lecture seule**.
ponytail : 2 providers décrits par 1 dict (pas de classe par provider) ; les
scopes au choix et un écran de connexion dédié arriveront si le besoin se
présente — l'état vit dans le catalogue du menu +.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from urllib.parse import urlencode
from app.config import settings
from app.db import get_conn
from app.services.calendar_sync import _decrypt_tokens, _encrypt_tokens
logger = logging.getLogger(__name__)
PROVIDERS: dict[str, dict] = {
"google": {
"name": "Google (Drive · Gmail · Calendar)",
"authorize": "https://accounts.google.com/o/oauth2/v2/auth",
"token": "https://oauth2.googleapis.com/token",
"api": "https://www.googleapis.com",
"scopes": [
"openid", "email", "profile",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
],
"extra": {"access_type": "offline", "include_granted_scopes": "true"},
"probe_path": "/oauth2/v3/userinfo",
},
"ms365": {
"name": "Microsoft 365 (Outlook · OneDrive)",
"authorize": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"token": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"api": "https://graph.microsoft.com/v1.0",
"scopes": [
"openid", "email", "profile", "offline_access",
"User.Read", "Files.Read", "Mail.Read", "Calendars.Read",
"ChannelMessage.Read.All", # Teams (phase 7) — consentement admin
],
"extra": {"response_mode": "query", "prompt": "consent"},
"probe_path": "/me",
},
}
OAUTH_KINDS = tuple(PROVIDERS)
# l'access token est renouvelé 60 s avant expiration
_REFRESH_SKEW = 60
# ── Config client ───────────────────────────────────────────────────────────
def _client(kind: str) -> tuple[str, str, str]:
"""(client_id, client_secret, redirect_uri) — lève si non configuré."""
if kind not in PROVIDERS:
raise ValueError(f"Connecteur OAuth inconnu: {kind}")
if kind == "google":
cid, secret = settings.google_client_id, settings.google_client_secret
else:
cid, secret = settings.ms_client_id, settings.ms_client_secret
if not cid or not secret:
raise ValueError(
f"Connecteur {kind} non configuré (GOOGLE_CLIENT_ID/SECRET ou MS_CLIENT_ID/SECRET)"
)
redirect = f"{settings.app_base_url.rstrip('/')}/api/agent/connectors/oauth/{kind}/callback"
return cid, secret, redirect
def callback_path(kind: str) -> str:
return f"/api/agent/connectors/oauth/{kind}/callback"
def _pkce_pair() -> tuple[str, str]:
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
# ── Flow ────────────────────────────────────────────────────────────────────
def begin(kind: str) -> dict:
"""URL d'autorisation + state + code_verifier (le route met les cookies)."""
cid, _secret, redirect = _client(kind)
verifier, challenge = _pkce_pair()
state = secrets.token_urlsafe(24)
params = {
"client_id": cid,
"redirect_uri": redirect,
"response_type": "code",
"scope": " ".join(PROVIDERS[kind]["scopes"]),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
params.update(PROVIDERS[kind].get("extra", {}))
return {
"url": f"{PROVIDERS[kind]['authorize']}?{urlencode(params)}",
"state": state,
"verifier": verifier,
"redirect_uri": redirect,
}
def _normalize(data: dict) -> dict:
expires_in = int(data.get("expires_in") or 3600)
return {
"access_token": str(data.get("access_token") or ""),
"refresh_token": str(data.get("refresh_token") or ""),
"scope": str(data.get("scope") or ""),
"expires_at": int(time.time()) + expires_in,
}
async def complete(kind: str, code: str, verifier: str) -> dict:
"""Échange le code contre des tokens (aucun réseau ici hors `_post_form`)."""
cid, secret, redirect = _client(kind)
data = await _post_form(PROVIDERS[kind]["token"], {
"client_id": cid,
"client_secret": secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect,
"code_verifier": verifier,
})
tokens = _normalize(data)
if not tokens["access_token"]:
raise ValueError(str(data.get("error_description") or data.get("error") or "échec de l'échange du code"))
return tokens
# ── Stockage (chiffré Fernet, comme calendar_sync) ──────────────────────────
def save(kind: str, user_id: int, tokens: dict) -> None:
with get_conn() as conn:
conn.execute(
"INSERT INTO connector_tokens (kind, user_id, tokens_enc, updated_at) "
"VALUES (?,?,?,CURRENT_TIMESTAMP) "
"ON CONFLICT(kind, user_id) DO UPDATE SET "
"tokens_enc=excluded.tokens_enc, updated_at=CURRENT_TIMESTAMP",
(kind, user_id, _encrypt_tokens(tokens)),
)
conn.commit()
def tokens(kind: str, user_id: int | None) -> dict:
if not user_id:
return {}
with get_conn() as conn:
row = conn.execute(
"SELECT tokens_enc FROM connector_tokens WHERE kind=? AND user_id=?",
(kind, user_id),
).fetchone()
return _decrypt_tokens(row["tokens_enc"]) if row else {}
def clear(kind: str, user_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute(
"DELETE FROM connector_tokens WHERE kind=? AND user_id=?", (kind, user_id)
)
conn.commit()
return cur.rowcount > 0
def is_connected(kind: str, user_id: int | None) -> bool:
return bool(tokens(kind, user_id).get("access_token"))
# ── Réseau ──────────────────────────────────────────────────────────────────
async def _post_form(url: str, data: dict) -> dict:
"""POST x-www-form-urlencoded vers le token endpoint → dict JSON.
Point d'injection des tests (on monkeypatche ``oauth_connectors._post_form``).
"""
from app.services.http_client import shared_client
async with shared_client(timeout=15, headers={"Accept": "application/json"}) as client:
resp = await client.post(url, data=data)
return resp.json()
async def _api_get(url: str, headers: dict) -> tuple[int, str]:
"""GET d'une API fournisseur — 2ᵉ point d'injection des tests."""
from app.services.http_client import shared_client
async with shared_client(timeout=15, headers=headers) as client:
resp = await client.get(url)
return resp.status_code, (resp.text or "")[:20000]
async def access_token(kind: str, user_id: int | None) -> str:
"""Access token valide, rafraîchi (refresh_token) si nécessaire."""
tok = tokens(kind, user_id)
if not tok.get("access_token"):
return ""
if tok.get("expires_at", 0) > time.time() + _REFRESH_SKEW:
return tok["access_token"]
if not tok.get("refresh_token"):
return "" # expiré sans refresh → à reconnexion
try:
cid, secret, redirect = _client(kind)
data = await _post_form(PROVIDERS[kind]["token"], {
"client_id": cid,
"client_secret": secret,
"grant_type": "refresh_token",
"refresh_token": tok["refresh_token"],
"redirect_uri": redirect,
})
fresh = _normalize(data)
if not fresh["access_token"]:
raise ValueError(data.get("error_description") or "refresh refusé")
# Google ne renvoie parfois pas de nouveau refresh_token : on garde l'ancien
fresh["refresh_token"] = fresh["refresh_token"] or tok["refresh_token"]
save(kind, int(user_id), fresh)
return fresh["access_token"]
except Exception as exc: # noqa: BLE001 — jamais d'exception qui casse un run
logger.warning("OAuth refresh failed (%s): %s", kind, exc)
return ""
async def api_get(kind: str, user_id: int | None, path: str = "") -> dict:
"""GET sur l'API du fournisseur avec le token de l'utilisateur."""
if kind not in PROVIDERS:
return {"status": "error", "text": f"Connecteur OAuth inconnu: {kind}"}
access = await access_token(kind, user_id)
if not access:
return {"status": "error", "text": f"{kind} non connecté (lancez la connexion OAuth)"}
if "://" in (path or ""):
return {"status": "error", "text": "path doit être relatif (pas d'URL absolue)"}
# base fixe + chemin : pas d'urljoin (une URL absolue ne peut pas dévier
# l'hôte), puis validation SSRF par principe.
url = PROVIDERS[kind]["api"].rstrip("/") + "/" + (path or "").lstrip("/")
from app.services.importers.url_fetch import _validate_url
try:
_validate_url(url)
except ValueError as exc:
return {"status": "error", "text": str(exc)}
code, text = await _api_get(url, {"Authorization": f"Bearer {access}"})
if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:20000]}
+77
View File
@@ -0,0 +1,77 @@
"""Plugins de l'instance — on/off à **effet réel** (v7.58.0).
Chaque plugin câble une chose concrète, jamais un simple drapeau :
- ``web-tools`` → retiré du registre d'outils de l'agent (schéma + exécution)
- ``web-clipper`` → routers ``/extensions`` et ``/api/v2/web-clipper/*`` refusés
- ``automations`` → routes ``/workspace/automations*`` refusées + scheduler
Le garde de route est une **dépendance FastAPI posée à l'`include_router`**
(``main.py``) : 3 lignes, aucun fichier de router touché.
"""
from __future__ import annotations
from fastapi import HTTPException, Request
from app.db import get_conn
# slug → (nom affiché, description affichée)
CATALOG: list[tuple[str, str, str]] = [
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
]
# slug → outils LLM retirés du registre quand le plugin est OFF
PLUGIN_TOOLS: dict[str, tuple[str, ...]] = {"web-tools": ("web_search", "fetch_url")}
def is_enabled(slug: str) -> bool:
"""Ligne absente = activé (défaut sûr : ne rien couper par accident)."""
with get_conn() as conn:
row = conn.execute("SELECT enabled FROM plugins WHERE slug=?", (slug,)).fetchone()
return True if row is None else bool(row["enabled"])
def list_plugins() -> list[dict]:
with get_conn() as conn:
rows = {r["slug"]: bool(r["enabled"])
for r in conn.execute("SELECT slug, enabled FROM plugins")}
return [{"slug": slug, "name": name, "description": desc,
"enabled": rows.get(slug, True)}
for slug, name, desc in CATALOG]
def set_enabled(slug: str, enabled: bool) -> dict:
if not any(s == slug for s, _, _ in CATALOG):
raise ValueError(f"Plugin inconnu: {slug}")
name = next(n for s, n, _ in CATALOG if s == slug)
desc = next(d for s, _, d in CATALOG if s == slug)
with get_conn() as conn:
# SQLite vérifie NOT NULL AVANT l'upsert : il faut fournir name/description.
conn.execute(
"INSERT INTO plugins (slug, name, description, enabled) VALUES (?,?,?,?) "
"ON CONFLICT(slug) DO UPDATE SET enabled=excluded.enabled",
(slug, name, desc, 1 if enabled else 0))
conn.commit()
return next(p for p in list_plugins() if p["slug"] == slug)
def plugin_required(slug: str):
"""Dépendance FastAPI : 404 dès que le plugin est désactivé.
`Request` doit être importé au **module** : les annotations sont des
chaînes (`from __future__ import annotations`) et FastAPI les résout dans
les globales du module — sinon il lit un query param → 422.
"""
def dep(request: Request) -> None:
# annotation Request OBLIGATOIRE : sans elle FastAPI lit un query param → 422
if not is_enabled(slug):
raise HTTPException(status_code=404, detail=f"Plugin désactivé: {slug}")
return dep
+103 -7
View File
@@ -1072,6 +1072,79 @@ class SearchCode(Tool):
# ══════════════════════════ Registry ══════════════════════════
class ConnectorFetch(Tool):
name = "connector_fetch"
description = (
"Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), "
"recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). "
"`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; "
"`query` sert au connecteur web. Les hôtes privés sont refusés."
)
parameters = {
"type": "object",
"properties": {
"connector": {"type": "string",
"description": "id, nom ou kind (gitea / github / web)"},
"path": {"type": "string", "description": "chemin relatif (API natives)"},
"query": {"type": "string", "description": "terme de recherche (web)"},
},
"required": ["connector"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services import connectors
try:
res = await connectors.connector_fetch(
str(args.get("connector") or ""),
str(args.get("path") or ""),
str(args.get("query") or ""),
user_id=user_id,
)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
logger.warning("connector_fetch failed: %s", exc)
return ToolResult(status="error", tool=self.name,
message=f"Connecteur indisponible: {exc}")
if res.get("status") != "ok":
return ToolResult(status="error", tool=self.name,
message=(res.get("text") or "erreur")[:500])
text = res.get("text") or ""
return ToolResult(status="success", tool=self.name, target_type="connector",
message=text[:800], data={"text": text})
class McpTool(Tool):
"""Outil MCP dynamique — instance construite à la volée depuis le cache
en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES."""
def __init__(self, entry: dict):
self.name = entry["name"]
self.description = entry.get("description") or "Outil MCP"
self.parameters = entry.get("parameters") or {"type": "object", "properties": {}}
self.entry = entry
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services import mcp_client
try:
res = await mcp_client.call_tool(self.entry["connector_id"],
self.entry.get("original") or "", args)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
logger.warning("MCP tool failed (%s): %s", self.name, exc)
return ToolResult(status="error", tool=self.name,
message=f"MCP indisponible: {exc}")
if res.get("status") != "ok":
return ToolResult(status="error", tool=self.name,
message=(res.get("text") or "erreur MCP")[:500])
text = res.get("text") or ""
return ToolResult(status="success", tool=self.name, target_type="mcp",
message=text[:800], data={"text": text})
TOOL_CLASSES = [
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
@@ -1080,7 +1153,7 @@ TOOL_CLASSES = [
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
WebSearch, FetchUrl, SearchCode,
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
]
@@ -1090,24 +1163,47 @@ class ToolRegistry:
def __init__(self):
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
def _all(self) -> dict[str, Tool]:
"""Outils statiques + **outils MCP dynamiques** (cache en base).
Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base,
donc « Tester le connecteur » suffit à rendre un outil disponible.
"""
tools = dict(self.tools)
try:
from app.services import mcp_client
from app.services import plugins as plugins_service
for entry in mcp_client.cached_tools():
tools[entry["name"]] = McpTool(entry)
# plugin « web-tools » OFF → outils web retirés du schéma ET de execute()
for slug, names in plugins_service.PLUGIN_TOOLS.items():
if not plugins_service.is_enabled(slug):
for name in names:
tools.pop(name, None)
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
logger.exception("dynamic tools load failed")
return tools
def list(self, scope: dict | None = None) -> list[str]:
"""Tool names allowed by an agent scope (default: all)."""
tools = self._all()
allowed = (scope or {}).get("tools")
if allowed is None:
return list(self.tools.keys())
return [n for n in self.tools if n in allowed]
return list(tools.keys())
return [n for n in tools if n in allowed]
def schema(self, scope: dict | None = None) -> list[dict]:
"""Function-calling schema for the LLM, filtered by scope."""
tools = self._all()
return [
{"name": self.tools[n].name,
"description": self.tools[n].description,
"parameters": self.tools[n].parameters}
{"name": tools[n].name,
"description": tools[n].description,
"parameters": tools[n].parameters}
for n in self.list(scope)
]
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
impl = self.tools.get(tool)
impl = self._all().get(tool)
if not impl:
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
return await impl.execute(args, user_id=user_id)
+3 -17
View File
@@ -125,21 +125,7 @@
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
</div>
<!-- Side Peek Panel for Database Pages (vanilla JS, piloté par database_table.js) -->
<div id="db-side-peek" class="db-side-peek">
<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>
<div class="db-side-peek-header">
<button id="db-side-peek-close" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="db-side-peek-title" id="db-side-peek-title"></div>
<button id="db-side-peek-full" title="Open full page">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</button>
</div>
<div class="db-side-peek-body">
<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>
<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
</div>
</div>
<!-- Le panneau de peek (#db-side-peek) est créé à la demande par
database_table.js (ensureDbPeek) : une seule source de markup pour
la page de base et les bases embarquées dans un document. -->
</div>
+1 -1
View File
@@ -37,7 +37,7 @@
{% if not hide_hamburger %}
<button class="hamburger-btn"
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
@click="{{ hamburger_click|default('fdHamburgerClick()') }}"
title="Toggle sidebar">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
+86 -1
View File
@@ -180,6 +180,26 @@ body.fd-ap-resizing *{cursor:col-resize!important}
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
/* Menu + (sections) — réutilise le skin du menu de mentions */
.fd-ap-plus-menu{max-height:min(340px,60vh)}
.fd-ap-plus-menu .fd-mention-item.disabled{opacity:.45;cursor:default;background:none}
.fd-ap-plus-menu .fd-mention-item.disabled:hover{background:none}
.fd-plus-head{display:flex;align-items:center;gap:8px;padding:7px 11px 6px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
.fd-plus-back{background:none;border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);border-radius:7px;width:26px;height:22px;cursor:pointer;line-height:1;flex-shrink:0}
.fd-plus-back:hover{background:var(--bg-hover,#2b2b2b)}
.fd-plus-title{font-size:11px;font-weight:600;color:var(--text-secondary,#ddd);text-transform:uppercase;letter-spacing:.4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-plus-filter{padding:7px 11px 2px}
.fd-plus-filter input{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:6px 8px;outline:none;box-sizing:border-box}
.fd-plus-form{padding:4px 11px 12px}
.fd-plus-form label{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:9px 0 3px}
.fd-plus-form input,.fd-plus-form textarea{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:7px 8px;outline:none;font-family:inherit;box-sizing:border-box}
.fd-plus-form textarea{min-height:110px;resize:vertical;line-height:1.5}
.fd-plus-file{display:none}
.fd-plus-chev{margin-left:auto;color:var(--text-dim,#999);opacity:.75;font-size:15px;flex-shrink:0}
.fd-plus-crumb{display:flex;gap:5px;flex-wrap:wrap;padding:7px 11px 3px;font-size:11px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
.fd-plus-crumb-it{color:var(--accent,#2383E2);cursor:pointer;white-space:nowrap}
.fd-plus-crumb-it:hover{text-decoration:underline}
/* Toast */
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
@@ -380,6 +400,71 @@ body.fd-ap-resizing *{cursor:col-resize!important}
</template>
</template>
</div>
<div class="fd-ap-mention-menu fd-ap-plus-menu" :class="{show: plusOpen}" x-ref="plusMenu">
<div class="fd-plus-head" x-show="plusSection!=='root'" x-cloak>
<button class="fd-plus-back" type="button" @click="plusBack()" title="Retour" aria-label="Retour">←</button>
<span class="fd-plus-title" x-text="plusTitle"></span>
</div>
<div class="fd-plus-filter" x-show="plusSection==='skills-gallery'" x-cloak>
<input type="search" placeholder="Filtrer les compétences…" aria-label="Filtrer les compétences" x-model="galleryFilter">
</div>
<div class="fd-plus-crumb" x-show="plusSection==='browse'">
<span class="fd-plus-crumb-it" @click="plusGoto(-1)">Racine</span>
<template x-for="(c, ci) in plusCrumb" :key="c.id">
<span class="fd-plus-crumb-it" @click="plusGoto(ci)" x-text="'› ' + c.name"></span>
</template>
</div>
<div class="fd-mention-load" x-show="plusSection==='browse' && plusLoading" x-cloak>Chargement…</div>
<div class="fd-mention-empty" x-show="plusSection==='browse' && !plusLoading && !plusItems.length" x-cloak>Dossier vide</div>
<template x-for="(it, i) in plusItems" :key="it.key">
<div>
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: i===plusFocus, disabled: !!it.disabled}"
@click="plusAction(it)" @mouseenter="it.disabled ? null : plusFocus = i">
<span class="fd-mention-ico" x-text="it.icon"></span>
<div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div>
<div class="fd-mention-sub" x-text="it.sub"></div>
</div>
<span class="fd-plus-chev" x-show="it.chev || it._folder">›</span>
</div>
</div>
</template>
<div class="fd-plus-form" x-show="plusSection==='skills-edit'" x-cloak>
<label for="fd-plus-skill-name">Nom</label>
<input id="fd-plus-skill-name" type="text" x-model="skillForm.name" placeholder="ex. synthese-reunion" @keydown.enter.prevent="saveSkill()">
<label for="fd-plus-skill-desc">Description</label>
<input id="fd-plus-skill-desc" type="text" x-model="skillForm.description" placeholder="Une phrase">
<label for="fd-plus-skill-prompt">Prompt (consigne appliquée à la demande)</label>
<textarea id="fd-plus-skill-prompt" x-model="skillForm.prompt_template" placeholder="Fais…"></textarea>
<div class="fd-proposal-bar">
<button class="fd-proposal-btn primary" type="button" @click="saveSkill()">✓ Enregistrer</button>
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="deleteSkill()" x-cloak>✕ Supprimer</button>
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="exportSkill()" x-cloak>⤓ Export</button>
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
</div>
</div>
<div class="fd-plus-form" x-show="plusSection==='connector-new'" x-cloak>
<label for="fd-plus-cn-kind">Type</label>
<select id="fd-plus-cn-kind" x-model="connectorForm.kind" @change="connectorPreset()">
<option value="custom">Connecteur personnalisé (HTTP + clé)</option>
<option value="discord">Discord (bot)</option>
<option value="telegram">Telegram (bot)</option>
<option value="mcp">Serveur MCP (streamable HTTP)</option>
</select>
<label for="fd-plus-cn-name">Nom</label>
<input id="fd-plus-cn-name" type="text" x-model="connectorForm.name" placeholder="ex. Base de connaissances" @keydown.enter.prevent="connectorCreate()">
<label for="fd-plus-cn-url" x-text="connectorForm.hint || 'URL publique (hôtes privés refusés)'"></label>
<input id="fd-plus-cn-url" type="text" x-model="connectorForm.url" placeholder="https://api.exemple.com">
<label for="fd-plus-cn-secret">Clé / jeton — chiffrée, jamais renvoyée</label>
<input id="fd-plus-cn-secret" type="password" x-model="connectorForm.secret" placeholder="sk-…" autocomplete="off">
<div class="fd-proposal-bar">
<button class="fd-proposal-btn primary" type="button" @click="connectorCreate()">✓ Ajouter</button>
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
</div>
</div>
<input type="file" accept="application/json,.json" x-ref="skillImport" class="fd-plus-file" aria-label="Importer un skill JSON" @change="importSkillFile($event)">
</div>
</div>
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
<div class="fd-chip-row" x-show="mentionChips.length">
@@ -406,7 +491,7 @@ body.fd-ap-resizing *{cursor:col-resize!important}
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
<div class="fd-ap-toolbar">
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
<button class="fd-ap-add" type="button" title="Ajouter au contexte : fichiers, répertoires, compétences…" aria-label="Menu d'ajout au contexte" aria-haspopup="menu" :aria-expanded="plusOpen" @click="toggleAddPicker()">+</button>
<span class="fd-ap-tip" x-text="composerHint"></span>
<div class="fd-ap-tools">
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
+21 -2
View File
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/settings.css?v={{ asset_version }}">
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
htmx lors d'une navigation partielle : la page revenait alors sans
@@ -1944,6 +1945,17 @@
this.sidebarCollapsed = !this.sidebarCollapsed;
},
// Hamburger: mobile opens the drawer (assignment expressions are illegal
// in the Alpine CSP build, hence this method), desktop toggles collapse.
fdHamburgerClick() {
if (window.matchMedia('(max-width: 768px)').matches) {
this.mobileSidebarOpen = true;
this.sidebarCollapsed = false;
} else {
this.toggleSidebar();
}
},
closeSidebar() {
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
this.sidebarCollapsed = true;
@@ -2093,9 +2105,16 @@
case 'openTab':
window.open('/' + pageId, '_blank');
break;
case 'openPeek':
this.toast('Side peek not available in browser', 'info');
case 'openPeek': {
// Route vers le panneau de peek DE LA PAGE courante (même
// éditeur allégé `?embed=1` que le bouton « ↢ Open »), jamais une
// navigation. En dehors de ces pages : repli sur un nouvel onglet.
var num = pageId.replace('page/', '');
if (window._libData && window._libData.openPeekById) window._libData.openPeekById(parseInt(num));
else if (window._wsData && window._wsData.openSidePeek) window._wsData.openSidePeek(parseInt(num));
else window.open('/pages/' + num, '_blank');
break;
}
}
this.contextMenu.visible = false;
},
+249
View File
@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block page_title %}Help{% endblock %}
{% block title_prefix %}Help{% endblock %}
{% block page_icon %}{{ fd_icon("help-circle",18) }}{% endblock %}
{% block topbar %}
{% set page_icon = "help-circle" %}
{% set page_title = "Help" %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
{# Same panel skeleton as settings (shared /static/css/settings.css) #}
<style>
.help-body{margin-bottom:24px;max-width:760px;}
.help-body p{font-size:14px;color:var(--text-secondary);line-height:1.65;margin:0 0 10px;}
.help-body ul{margin:0 0 14px;padding-left:18px;}
.help-body li{font-size:14px;color:var(--text-secondary);line-height:1.6;margin-bottom:4px;}
.help-body code{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:12.5px;}
.help-body h3{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin:22px 0 8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
.help-shortcut-row:hover{background:var(--bg-hover);}
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="settings-overlay" x-data="helpInit()" style="background:none;backdrop-filter:none;position:static;z-index:1;padding:0;">
<div class="settings-panel" style="width:100%;max-width:1050px;height:calc(100vh - 120px);margin:0 auto;" @keydown.escape="navOpen = false">
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="settings-close" @click="close()" title="Close" aria-label="Close help">×</button>
<!-- Mobile drawer backdrop -->
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
<!-- Left navigation -->
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
<div class="settings-nav-header">Basics</div>
<div class="settings-nav-item" :class="{ active: section==='start' }" @click="go('start')">🚀 Getting Started</div>
<div class="settings-nav-item" :class="{ active: section==='editor' }" @click="go('editor')">📝 Pages &amp; Editor</div>
<div class="settings-nav-item" :class="{ active: section==='databases' }" @click="go('databases')">🗄️ Databases &amp; Views</div>
<div class="settings-nav-item" :class="{ active: section==='tasks' }" @click="go('tasks')">✅ Tasks &amp; Dependencies</div>
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: section==='workspaces' }" @click="go('workspaces')">📁 Workspaces &amp; Forges</div>
<div class="settings-nav-item" :class="{ active: section==='collab' }" @click="go('collab')">👥 Collaboration</div>
<div class="settings-nav-item" :class="{ active: section==='nav' }" @click="go('nav')">📚 Library, Trash &amp; Search</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: section==='agent' }" @click="go('agent')">🤖 AI Agent &amp; Automations</div>
<div class="settings-nav-item" :class="{ active: section==='integrations' }" @click="go('integrations')">🔌 Integrations &amp; API</div>
<div class="settings-nav-item" :class="{ active: section==='pwa' }" @click="go('pwa')">📶 Offline &amp; PWA</div>
<div class="settings-nav-item" :class="{ active: section==='auth' }" @click="go('auth')">🔐 Accounts &amp; SSO</div>
<div class="settings-nav-header">Reference</div>
<div class="settings-nav-item" :class="{ active: section==='shortcuts' }" @click="go('shortcuts')">⌨️ Keyboard Shortcuts</div>
<div class="settings-nav-item" :class="{ active: section==='tips' }" @click="go('tips')">💡 Tips</div>
</div>
<!-- Content -->
<div class="settings-content">
<div class="help-body" x-show="section==='start'">
<h2>Getting Started</h2>
<p>FlowDeck is your private, self-hosted Notion-style workspace — pages, databases, and Git-forge (Gitea / GitHub) integration in one app.</p>
<h3>First steps</h3>
<ul>
<li>Open <b>Workspaces</b> (<code>/workspaces</code>) to pick or create a workspace — local or connected to a forge.</li>
<li>Click <b>+ New page</b> in the sidebar to start writing, or press <span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span>.</li>
<li>Use the <b>Agent &amp; IA</b> section in Settings to plug an LLM provider (OpenAI, Ollama, DeepInfra…) if you want AI features.</li>
</ul>
<h3>The sidebar</h3>
<ul>
<li>The workspace header (top-left) switches workspaces and opens your account menu.</li>
<li>Tabs <b>Home / Chat / Meetings / Inbox</b> swap the sidebar content; pages are a drag-and-drop tree.</li>
<li>On mobile the sidebar is a drawer: open it with the hamburger button, close it by tapping outside or swiping left.</li>
</ul>
</div>
<div class="help-body" x-show="section==='editor'">
<h2>Pages &amp; Editor</h2>
<p>Notion-style block editor: every line is a block you can drag, convert, and nest.</p>
<h3>Blocks</h3>
<ul>
<li>Type <code>/</code> for the slash menu — headings, callouts, toggles, code, math (KaTeX), tables, images, embeds, ToC…</li>
<li>Markdown shortcuts as you type: <code># </code> heading, <code>- </code> bullet, <code>1. </code> numbered, <code>&gt; </code> quote, <code>[] </code> todo, <code>``` </code> code.</li>
<li>Drag the handle left of a block to move or nest it; <span class="help-kbd">Tab</span>/<span class="help-kbd">Shift+Tab</span> indent/outdent.</li>
<li>Inline formatting: bold, italic, code, links, colors via the selection toolbar.</li>
</ul>
<h3>Page features</h3>
<ul>
<li>Cover image and icon on every page (click the top of the page).</li>
<li>Inline databases: add a table/board/calendar collection directly in a page.</li>
<li>Comments (threaded), page history with snapshots, backlinks.</li>
<li>Right-click any sidebar item for context menu: duplicate, rename, move, delete.</li>
</ul>
</div>
<div class="help-body" x-show="section==='databases'">
<h2>Databases &amp; Views</h2>
<p>Collections are databases with a JSON schema, independent of Gitea. <b>21 property types</b>: title, text, number, select, multi-select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula, and auto-properties.</p>
<h3>Views</h3>
<ul>
<li><b>Table</b> — sortable columns, inline editing.</li>
<li><b>Board (Kanban)</b> — group by select/status, drag &amp; drop between columns.</li>
<li><b>Calendar</b> — month grid by date property.</li>
<li><b>Gallery</b> — visual cards, configurable card size.</li>
<li><b>List</b> — compact rows with preview.</li>
<li><b>Timeline</b> — Gantt bars from date ranges.</li>
<li><b>Status Overview / Team Load</b> — donut + stacked-bar dashboards.</li>
</ul>
<h3>Relations, rollups, formulas</h3>
<ul>
<li>Relations are bidirectional: link rows across collections.</li>
<li>Rollups aggregate related rows (12 aggregations: sum, count, average, min, max…).</li>
<li>Formulas support 19 functions over row properties.</li>
<li>Filters, sorts and grouping apply per-view.</li>
</ul>
</div>
<div class="help-body" x-show="section==='tasks'">
<h2>Tasks &amp; Dependencies</h2>
<ul>
<li>Todo blocks anywhere in pages become checkboxes; the <b>My Tasks</b> page aggregates every task in every collection you can access.</li>
<li>My Tasks views: <b>All / Today / Overdue / Next 7 days</b>.</li>
<li><b>Sub-items</b>: unlimited hierarchy — a parent row is Done when all children are Done (status aggregate).</li>
<li><b>Dependencies</b>: Blocking / Blocked by between rows; transitioning a blocked task is refused until its blocker is done.</li>
</ul>
</div>
<div class="help-body" x-show="section==='workspaces'">
<h2>Workspaces &amp; Forges</h2>
<p>Organize work into separate workspaces, each with its own page tree, collections and members.</p>
<ul>
<li><span class="help-badge local">Local</span> Pages and files stored on your server (SQLite under <code>/data</code>).</li>
<li><span class="help-badge gitea">Gitea</span> Connect your Gitea account (Settings → Integrations): repos appear as workspaces, browse the file tree in the sidebar, create/edit files with commit messages, sync labels as tags.</li>
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations for repo browsing.</li>
<li>Gitea legacy boards are auto-adapted into FlowDeck collections (GiteaBoardCompat).</li>
<li>The <b>Private</b> section appears when a remote workspace is active — those pages stay local.</li>
</ul>
</div>
<div class="help-body" x-show="section==='collab'">
<h2>Collaboration</h2>
<ul>
<li><b>Roles</b>: workspace members are admin / editor / commenter / viewer.</li>
<li><b>Sharing</b>: the Share button (top-right of a page) — share with specific users or publish a read-only public link (<code>/p/your-slug</code>).</li>
<li><b>Teamspaces</b> (wiki): shared knowledge spaces with their own pages.</li>
<li><b>Meetings</b>: meeting notes with an action-items panel.</li>
<li><b>Sprints</b>: time-box collections of tasks, with burndown-ready statuses.</li>
<li><b>Notifications</b>: bell in the topbar for mentions, assignments, comments.</li>
<li>Live editing: changes propagate to other open tabs (real-time sync).</li>
</ul>
</div>
<div class="help-body" x-show="section==='nav'">
<h2>Library, Trash &amp; Search</h2>
<ul>
<li><b>Library</b> (<code>/library</code>) — tabs for Recents, Favorites, Shared, Published, with filtering.</li>
<li><b>Trash</b> (<code>/trash</code>) — soft-deleted pages, 30-day retention, restore or purge.</li>
<li><b>Ctrl+K</b> — command palette: fuzzy search across pages, jump anywhere, start an AI answer.</li>
<li>Full-text search API: <code>/api/v2/search</code>.</li>
</ul>
</div>
<div class="help-body" x-show="section==='agent'">
<h2>AI Agent &amp; Automations</h2>
<ul>
<li><b>Chat sidebar</b> (Chat tab): conversations with your configured LLM; pages can be attached as context.</li>
<li><b>Agent panel</b> on pages: propose edits, generate content, answer questions about the workspace.</li>
<li><b>Skill marketplace</b>: export/import portable skills, install presets from the <code>/</code> gallery.</li>
<li><b>Automations</b> (Settings → Automations): visual pipeline of trigger → condition → delay → action (webhook, notification, page update…), with run history and legacy JSON conversion.</li>
<li>Configure providers in <b>Settings → Agent &amp; IA</b>: per-provider API keys, model lists, offline mode without any provider.</li>
</ul>
</div>
<div class="help-body" x-show="section==='integrations'">
<h2>Integrations &amp; API</h2>
<ul>
<li><b>Public API v2</b> — <code>/api/v2</code>: CRUD on collections, pages, properties, views, comments, notifications, favorites, tags, sharing, sprints, templates. Bearer tokens (Settings → API tokens) with scopes <code>read</code>/<code>write</code>/<code>admin</code>, pagination, filters, sorting, idempotency keys, RFC 7807 errors. Interactive docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>.</li>
<li><b>Agent API</b> — <code>/api/v2/agents/*</code>: agents, conversations, synchronous JSON run, action journal with rollback, external trigger.</li>
<li><b>Webhooks</b>: outgoing events (create/update/delete) managed in Settings → Integrations; incoming Gitea webhooks keep boards in sync.</li>
<li><b>Web Clipper</b> — browser extension (Manifest V3): clip article / selection / bookmark / screenshot straight into FlowDeck.</li>
<li><b>Import / Export</b>: CSV import, JSON/CSV/Markdown export.</li>
</ul>
</div>
<div class="help-body" x-show="section==='pwa'">
<h2>Offline mode (PWA)</h2>
<p>FlowDeck is a Progressive Web App: pages you visited stay available offline and your edits are saved locally, then synchronised when the connection returns.</p>
<ul>
<li><b>Install:</b> browser menu → <i>Install app</i> (Chrome/Edge) or <i>Add to Home Screen</i> (Safari/iOS).</li>
<li><b>Offline editing:</b> while offline, the editor stores changes in the browser (IndexedDB) and shows an offline banner with the number of pending changes. A ⟳ icon marks pages with unsynced edits.</li>
<li><b>Reconnection:</b> the queue is replayed automatically (and via Background Sync); a spinner badge appears while syncing, then a confirmation toast.</li>
<li><b>Conflicts:</b> the latest edit wins with a notice; server-side-deleted pages are recreated as orphan pages; title collisions get an <i>“…(copie offline)”</i> suffix.</li>
</ul>
</div>
<div class="help-body" x-show="section==='auth'">
<h2>Accounts &amp; SSO</h2>
<p>
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br><br>
<span class="help-badge gitea">Gitea OAuth</span> / <span class="help-badge github">GitHub OAuth</span> — sign in with your forge; your repos become workspaces. You can link a forge to an existing local account — your identity stays local.<br><br>
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> — in <b>Settings → Admin → SSO / Enterprise</b>:
</p>
<ul>
<li><b>SAML 2.0</b> — paste IdP Entity ID, SSO URL and signing certificate; give the IdP your <code>/auth/saml/metadata</code> link.</li>
<li><b>OpenID Connect</b> — Issuer URL, Client ID and Client Secret (PKCE, scopes <code>openid profile email</code>).</li>
<li>Just-in-time provisioning, IdP groups mapped to workspace roles, <i>SSO only</i> mode disables local login (admins keep their door). Every attempt is audited in the login history.</li>
</ul>
<p>Sessions and API tokens are managed in Settings → Sessions / API tokens.</p>
</div>
<div class="help-body" x-show="section==='shortcuts'">
<h2>Keyboard Shortcuts</h2>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Command palette / quick find</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (footer)</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">/</span></div><div class="desc">Slash command menu (in editor)</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Tab</span> / <span class="help-kbd">Shift</span>+<span class="help-kbd">Tab</span></div><div class="desc">Indent / outdent block</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
</div>
<div class="help-body" x-show="section==='tips'">
<h2>Tips</h2>
<ul>
<li>Toggle the desktop sidebar with the « button; collapsed, hover the left edge to peek it.</li>
<li><b>Ctrl+Click</b> / <b>Shift+Click</b> multi-select in the sidebar tree.</li>
<li>Hover any sidebar item for quick actions (favorite, share, delete).</li>
<li>Drag pages into the tree to reorganize; drop onto a page to nest it.</li>
<li>The section <b>⋮</b> menus reorder or hide sidebar sections to taste.</li>
<li>Dark/light theme toggle is in the topbar and persists across reloads.</li>
</ul>
</div>
</div>
</div>
</div>
<script data-cfasync="false" src="/static/js/help.js?v={{ asset_version }}"></script>
{% endblock %}
+3 -3
View File
@@ -150,7 +150,7 @@
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
.peek-header button svg{width:15px;height:15px;}
.peek-body{flex:1;overflow-y:auto;padding:16px 20px;}
.peek-body{flex:1;overflow:hidden;padding:0;}
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
@@ -450,8 +450,8 @@
<!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en
permanence, ce qui permet de piloter `src` en JS (le build CSP
d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
Charger `/pages/{id}` sans `?embed=1` : c'est exactement l'editeur
plein ecran, le peek n'est plus une variante allegee. -->
Charger `/pages/{id}?embed=1` : le peek affiche uniquement le
document en mode edition, sans sidebar ni barre de navigation. -->
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
<div class="peek-loading" id="lib-peek-loading">Chargement…</div>
<iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
+5
View File
@@ -8,4 +8,9 @@
{% endblock %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
{# database_table.js = FlowDeckDB + le panneau de peek des bases : sans cet
include, une base embarquée dans un document restait bloquée à
« Loading database… » dans le peek et l'icône Open de fin de ligne ne
faisait rien. #}
{% include '_database_table_scripts.html' %}
{% endblock %}
+15 -21
View File
@@ -11,16 +11,6 @@
{% block content %}
<style>
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
.settings-nav-item:hover{background:var(--bg-hover);}
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
.setting-group{margin-bottom:28px;}
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
@@ -69,9 +59,7 @@
.status-dot.active{background:var(--toast-success-bg);}
.status-dot.inactive{background:var(--danger);}
/* Close button */
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Close button — shared styles in /static/css/settings.css */
/* Agent & IA — status summary + provider cards */
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
@@ -125,26 +113,32 @@
.llm-detail .prov-field-row{margin-top:12px;}
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
/* ── Mobile side-nav drawer: shared settings.css handles overlay/nav/panel ── */
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
<div class="settings-panel" style="position:relative;">
<div class="settings-overlay" x-data="settingsInit()">
<div class="settings-panel" style="position:relative;" @keydown.escape="historyBack()">
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
<!-- Left navigation -->
<div class="settings-nav">
<!-- Left navigation (mobile: side-navigation drawer via hamburger + backdrop) -->
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
<div class="settings-nav-header">Account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
{% if plugin_enabled('web-clipper') %}<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>{% endif %}
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
{% if plugin_enabled('automations') %}<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>{% endif %}
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
@@ -423,7 +417,7 @@
</div>
<!-- Extensions (Web Clipper) -->
<div x-show="activeSection==='extensions'">
<div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} &amp;&amp; activeSection==='extensions'">
<h2>Extensions</h2>
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
<div class="setting-group">
@@ -584,7 +578,7 @@
</div>
<!-- Automations -->
<div x-show="activeSection==='automations'">
<div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} &amp;&amp; activeSection==='automations'">
<h2>Automations</h2>
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
+8
View File
@@ -77,3 +77,11 @@ except OSError: # pragma: no cover
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
def _plugin_enabled(slug: str) -> bool:
from app.services.plugins import is_enabled
return is_enabled(slug)
+253
View File
@@ -0,0 +1,253 @@
# V74 — Menu + de l'assistant : hub de contexte (design)
> **Statut** : design — **phases 1 à 6 livrées (v7.51.0 → v7.56.0, 2026-10-06)** ;
> phases 7-8 restent à livrer. La **mémoire** a été simplifiée par rapport au
> modèle ici : une seule ligne résumé **par conversation** (pas de lignes
> `workspace_id NULL` — rien ne les écrivait), extraction déterministe sans LLM. Adaptations faites en cours de route : le parcours
> utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat
> dossier = `icon === 'folder'`), 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.
## 9bis. Avenant phase 7 (v7.57.0) — décisions livrées
- **D5 (`probe/list_sources/fetch` par adapter) écartée** : 1 switch sur
`agent_connectors.kind` dans `probe()` / `connector_fetch()` — une interface
à 3 méthodes pour 4 kinds tordrait plus qu'elle ne simplifie ; ajouter un
kind = 1 branche de plus dans le même switch.
- **Discord / Telegram = presets du socle**, pas 3 adapters dédiés : le champ
**Type** du formulaire pré-remplit l'URL + le schéma d'auth
(`bearer`/`bot`/`none`). La Bot API de Telegram impose le jeton **dans l'URL**
→ substitut `{secret}` remplacé à l'appel (`connectors._with_secret`),
stockage = motif seul, test « le jeton n'est jamais en base ».
- **MCP : outils dynamiques sans état** — `ToolRegistry._all()` relit le cache
`tools_json` à chaque run (rien à invalider, « Tester » suffit) ; nom LLM
`mcp_<serveur>_<outil>` (slug sans double soulignement) ; dispatch
`tools/call` dans `McpTool.execute()`. Serveur désactivé → outil **absent du
schéma**, échec réseau → `ToolResult(error)` : le run continue toujours.
- **Pas de client SSE à l'état** : `parse_body()` lit le JSON direct ou le
premier `data:` d'un `text/event-stream`, ce qui couvre
`initialize` / `tools/list` / `tools/call` des serveurs streamables courants.
Upgrade : client SSE persistant si un serveur ne répond qu'en flux.
## 9ter. Avenant phase 8 (v7.58.0) — décisions livrées
- **Garde de route = dépendance posée à l'`include_router`** (`main.py`), pas
de décorateur par route ni de middleware : 3 lignes, aucun router modifié.
⚠️ l'annotation `request: Request` doit être importée **au module** : avec
`from __future__ import annotations` les annotations sont des chaînes et
FastAPI les résout dans les globales du module — sinon il lit un query param
→ 422 (piège réel, corrigé en cours de phase).
- **Effet réel aux 4 niveaux** : routes (dépendance), arrière-plan (garde de
tick du scheduler), outils (`ToolRegistry._all()`), UI (rendu serveur).
- **UI : `{% if %}` pour la nav, `x-show` pour les sections** — la nav est
**absente du DOM** (test « UI absente » au sens propre) ; les grosses
sections ne sont pas restructurées, leur booléen est rendu au serveur.
- **Handler unifié des 404** (comportement historique conservé) : hors `/api`
une route refusée **redirige 302 → /workspaces**, `/api*` répond 404 JSON —
les tests assertent les deux formes au lieu de réécrire le handler.
- **SQLite** : `INSERT … ON CONFLICT` évalue NOT NULL **avant** l'upsert →
fournir toutes les colonnes NOT NULL, même pour un simple changement d'état.
- `ponytail:` plafond assumé — pas de classe-adapter par plugin ni de
« marketplace » : ajouter un plugin = 1 tuple dans `CATALOG` (+ 1 garde si
son effet n'est pas déjà couvert).
## 10. Hors périmètre
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
- 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).
+564 -9
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.49.2"
"version": "7.59.2"
},
"paths": {
"/auth/register": {
@@ -1098,7 +1098,7 @@
"dashboard"
],
"summary": "Help Page",
"description": "Comprehensive help & documentation page.",
"description": "Help & documentation page — settings-style panel with side navigation.",
"operationId": "help_page_help_get",
"responses": {
"200": {
@@ -3706,6 +3706,57 @@
}
}
},
"/board/api/page-templates/{template_id}/blocks": {
"get": {
"tags": [
"board"
],
"summary": "Page Template Blocks",
"description": "v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.\n\nBuiltin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).\nCanevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le\nfront appelle ``ensureBlockIds()`` (déjà global côté éditeur).",
"operationId": "page_template_blocks_board_api_page_templates__template_id__blocks_get",
"parameters": [
{
"name": "template_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Template Id"
}
},
{
"name": "key",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "Key"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/board/library": {
"get": {
"tags": [
@@ -15906,6 +15957,96 @@
}
}
},
"/api/agent/skills/{skill_id}": {
"patch": {
"tags": [
"agent"
],
"summary": "Update Skill",
"description": "Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).\n\nSeuls les champs présents dans ``body`` sont mis à jour ; la liste des\ncolonnes est figée ici (jamais interpolée depuis la requête).",
"operationId": "update_skill_api_agent_skills__skill_id__patch",
"parameters": [
{
"name": "skill_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Skill Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"agent"
],
"summary": "Delete Skill",
"operationId": "delete_skill_api_agent_skills__skill_id__delete",
"parameters": [
{
"name": "skill_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Skill Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/skills/{skill_id}/apply": {
"post": {
"tags": [
@@ -16099,21 +16240,435 @@
}
}
},
"/api/agent/skills/{skill_id}": {
"delete": {
"/api/agent/connectors": {
"get": {
"tags": [
"agent"
],
"summary": "Delete Skill",
"operationId": "delete_skill_api_agent_skills__skill_id__delete",
"summary": "List Connectors Route",
"description": "Catalogue : natifs (statut dérivé de la config) + personnels.",
"operationId": "list_connectors_route_api_agent_connectors_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
},
"post": {
"tags": [
"agent"
],
"summary": "Create Connectors Route",
"description": "Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF).",
"operationId": "create_connectors_route_api_agent_connectors_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"title": "Body",
"default": {}
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/plugins": {
"get": {
"tags": [
"agent"
],
"summary": "List Plugins Route",
"description": "Catalogue des plugins de l'instance (menu + → « Add plugins »).",
"operationId": "list_plugins_route_api_agent_plugins_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/agent/plugins/{slug}": {
"patch": {
"tags": [
"agent"
],
"summary": "Set Plugin Route",
"description": "Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau.",
"operationId": "set_plugin_route_api_agent_plugins__slug__patch",
"parameters": [
{
"name": "skill_id",
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Slug"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/{connector_id}": {
"patch": {
"tags": [
"agent"
],
"summary": "Update Connectors Route",
"operationId": "update_connectors_route_api_agent_connectors__connector_id__patch",
"parameters": [
{
"name": "connector_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Skill Id"
"title": "Connector Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"agent"
],
"summary": "Delete Connectors Route",
"operationId": "delete_connectors_route_api_agent_connectors__connector_id__delete",
"parameters": [
{
"name": "connector_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Connector Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/probe": {
"post": {
"tags": [
"agent"
],
"summary": "Probe Connectors Route",
"description": "Teste un connecteur (id, nom ou kind natif) et persiste le résultat.",
"operationId": "probe_connectors_route_api_agent_connectors_probe_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"title": "Body",
"default": {}
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/oauth/{kind}/status": {
"get": {
"tags": [
"agent"
],
"summary": "Connector Oauth Status",
"description": "État du connecteur OAuth pour l'utilisateur courant.",
"operationId": "connector_oauth_status_api_agent_connectors_oauth__kind__status_get",
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Kind"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/oauth/{kind}/authorize": {
"post": {
"tags": [
"agent"
],
"summary": "Connector Oauth Authorize",
"description": "URL d'autorisation (PKCE) + cookies d'état éphémères (10 min).",
"operationId": "connector_oauth_authorize_api_agent_connectors_oauth__kind__authorize_post",
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Kind"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/oauth/{kind}/callback": {
"get": {
"tags": [
"agent"
],
"summary": "Connector Oauth Callback",
"description": "Reçoit le code du fournisseur, échange, stocke, renvoie sur la page.",
"operationId": "connector_oauth_callback_api_agent_connectors_oauth__kind__callback_get",
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Kind"
}
},
{
"name": "code",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "Code"
}
},
{
"name": "state",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "State"
}
},
{
"name": "error",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "Error"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/oauth/{kind}/disconnect": {
"post": {
"tags": [
"agent"
],
"summary": "Connector Oauth Disconnect",
"operationId": "connector_oauth_disconnect_api_agent_connectors_oauth__kind__disconnect_post",
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Kind"
}
}
],
@@ -29889,4 +30444,4 @@
}
}
}
}
}
+163
View File
@@ -0,0 +1,163 @@
// Mobile regression: hamburger drawer, wheel scroll, settings side-nav, help page.
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-pass-123';
// One login for the whole file (app rate limiter: 60 req/min per IP) — reused
// as storageState by every test.
let STATE = null;
test.beforeAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 10000 }).then(() => true).catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E Mobile' } });
if (!resp.ok() && resp.status() !== 409) throw new Error(`register ${resp.status()}`);
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 });
}
STATE = await ctx.storageState();
await ctx.close();
});
async function login(page) {
// Session reused from the single beforeAll login (rate limiter: 60 req/min/IP)
await page.context().addCookies(STATE.cookies);
}
test.describe('mobile', () => {
test.use({ viewport: { width: 390, height: 844 } });
test('hamburger opens mobile sidebar drawer', async ({ page }) => {
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
const burger = page.locator('.hamburger-btn');
await expect(burger).toBeVisible();
// Sidebar starts off-canvas
const sidebar = page.locator('#sidebar');
const boxBefore = await sidebar.boundingBox();
expect(boxBefore.x).toBeLessThan(0);
await burger.click();
await expect(sidebar).toHaveClass(/mobile-open/, { timeout: 5000 });
const boxAfter = await sidebar.boundingBox();
expect(boxAfter.x).toBeGreaterThanOrEqual(0);
expect(errs).toEqual([]);
// Overlay tap closes it
await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 400 } });
await expect(sidebar).not.toHaveClass(/mobile-open/, { timeout: 5000 });
});
test('mobile sidebar drawer scrolls with mouse wheel', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.locator('.hamburger-btn').click();
await page.waitForSelector('#sidebar.mobile-open');
const sc = page.locator('.sidebar-scroll');
await expect.poll(async () => sc.evaluate((el) => el.scrollHeight > el.clientHeight), { timeout: 3000 }).toBe(true);
const box = await sc.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
await page.mouse.wheel(0, 300);
await expect.poll(async () => sc.evaluate((el) => el.scrollTop), { timeout: 3000 }).toBeGreaterThan(0);
});
test('settings page fits mobile viewport', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
const panel = page.locator('.settings-panel');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.width).toBeLessThanOrEqual(390);
expect(box.height).toBeLessThanOrEqual(844);
// No horizontal page overflow
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
test('settings mobile nav is a hamburger side drawer', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
const burger = page.locator('.settings-menu-btn');
await expect(burger).toBeVisible();
const nav = page.locator('.settings-nav');
const before = await nav.boundingBox();
expect(before.x + before.width).toBeLessThanOrEqual(1);
await burger.click();
await expect(nav).toHaveClass(/nav-open/);
// Nav item switches section AND closes the drawer
await page.locator('.settings-nav-item', { hasText: 'Tags' }).click();
await expect(nav).not.toHaveClass(/nav-open/);
await expect(page.locator('h2', { hasText: 'Tags' }).first()).toBeVisible();
// Re-open and dismiss via backdrop
await burger.click();
await expect(nav).toHaveClass(/nav-open/);
await page.locator('.settings-nav-backdrop').click({ position: { x: 375, y: 500 } });
await expect(nav).not.toHaveClass(/nav-open/);
});
test('help page uses settings-style side navigation', async ({ page }) => {
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
await login(page);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
// Same panel skeleton as settings
await expect(page.locator('.settings-panel')).toBeVisible();
await expect(page.locator('.settings-menu-btn')).toBeVisible();
// Mobile: nav drawer behind hamburger
const nav = page.locator('.settings-nav');
await expect(nav).not.toHaveClass(/nav-open/);
await page.locator('.settings-menu-btn').click();
await expect(nav).toHaveClass(/nav-open/);
await page.locator('.settings-nav-item', { hasText: 'Databases' }).click();
await expect(nav).not.toHaveClass(/nav-open/);
await expect(page.locator('h2', { hasText: 'Databases & Views' })).toBeVisible();
// App-wide coverage: all sections exist in the nav
const sections = ['Getting Started', 'Pages & Editor', 'Databases & Views', 'Tasks & Dependencies',
'Workspaces & Forges', 'Collaboration', 'Library, Trash & Search', 'AI Agent & Automations',
'Integrations & API', 'Offline & PWA', 'Accounts & SSO', 'Keyboard Shortcuts', 'Tips'];
const labels = (await nav.locator('.settings-nav-item').allInnerTexts()).join(' ');
for (const s of sections) expect(labels, s).toContain(s);
expect(errs).toEqual([]);
});
});
test.describe('desktop', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('settings and help keep the 2-column layout on desktop', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.settings-menu-btn')).toBeHidden();
const nav = page.locator('.settings-nav');
await expect(nav).toBeVisible();
const navBox = await nav.boundingBox();
expect(navBox.height).toBeGreaterThan(navBox.width);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.settings-menu-btn')).toBeHidden();
await page.locator('.settings-nav-item', { hasText: 'SSO' }).click();
await expect(page.locator('h2', { hasText: 'Accounts & SSO' })).toBeVisible();
await expect(page.locator('.settings-content')).toContainText('IndexedDB');
});
});
+37
View File
@@ -0,0 +1,37 @@
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
test.use({ viewport: { width: 390, height: 844 } });
test('diagnose mobile sidebar wheel scroll', async ({ page }) => {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', process.env.FD_USER || '[email protected]');
await page.fill('#password', process.env.FD_PASS || 'e2e-pass-123');
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 }).catch(() => {});
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelector('.hamburger-btn'));
await page.locator('.hamburger-btn').click();
await page.waitForSelector('#sidebar.mobile-open');
const info = await page.evaluate(() => {
const sb = document.getElementById('sidebar');
const sc = document.querySelector('.sidebar-scroll');
const cs = getComputedStyle(sb);
const ccs = sc ? getComputedStyle(sc) : null;
return {
sidebar: { display: cs.display, height: cs.height, overflow: cs.overflow, sh: sb.scrollHeight, ch: sb.clientHeight },
scroll: sc ? { top: sc.scrollTop, sh: sc.scrollHeight, ch: sc.clientHeight, oy: ccs.overflowY, flex: ccs.flex, rect: JSON.stringify(sc.getBoundingClientRect()) } : null,
wheelTarget: (() => { const el = document.elementFromPoint(150, 400); return el ? el.className : 'none'; })(),
};
});
console.log('DIAG:', JSON.stringify(info, null, 1));
await page.mouse.move(150, 400);
await page.mouse.wheel(0, 300);
await page.waitForTimeout(300);
const after = await page.evaluate(() => {
const sc = document.querySelector('.sidebar-scroll');
return { scTop: sc.scrollTop, scSH: sc.scrollHeight, scCH: sc.clientHeight, bodyTop: document.scrollingElement.scrollTop };
});
console.log('AFTER WHEEL:', JSON.stringify(after));
});
+19 -9
View File
@@ -2246,13 +2246,15 @@ button.breadcrumb-current { max-width: 260px; }
/* Page de base (premier bloc = embed collection) : le titre et le tableau
s'alignent sur la marge gauche de la topbar (16px) au lieu d'être centrés
dans la colonne 900px — le tableau démarre au ras du sidebar. */
dans la colonne 900px — le tableau démarre au ras du sidebar et prend la
LARGEUR DU DOCUMENT (les colonnes dictent le scroll horizontal). */
.page-editor-wrapper.db-first .page-cover-area,
.page-editor-wrapper.db-first .page-title-block,
.page-editor-wrapper.db-first .blocks-container {
max-width: 1100px;
max-width: 100%;
margin: 0;
padding-left: var(--space-lg);
padding-right: var(--space-lg);
}
/* Une base embarquée ne doit jamais déborder de sa colonne de blocs :
@@ -2262,7 +2264,7 @@ button.breadcrumb-current { max-width: 260px; }
/* Titre des pages de base dédiées (page_editor_collection.html) — même
liseré gauche que .database-table-container. */
.database-page-title {
max-width: 1100px;
max-width: 100%;
margin: 0;
padding: 28px var(--space-lg) 0;
font-size: 30px;
@@ -2474,11 +2476,20 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 16px;
}
/* Numbered list */
/* Numbered list — data-num est calculé au rendu (rangée contiguë) */
.block-numbered {
padding-left: 22px;
padding-left: 32px;
position: relative;
}
.block-numbered[data-num]::before {
content: attr(data-num) ". ";
position: absolute;
left: 0;
top: 0;
min-width: 24px;
color: var(--text-primary);
font-size: 16px;
}
/* To-do */
.block-todo {
@@ -3422,7 +3433,7 @@ button.breadcrumb-current { max-width: 260px; }
/* ── Sidebar mobile ── */
.sidebar {
display: block;
display: flex; flex-direction: column; /* block cassait flex:1 de .sidebar-scroll → drawer impossible à scroller */
position: fixed !important;
top: 0; left: 0;
bottom: 0; width: 85vw; max-width: 320px;
@@ -3899,11 +3910,10 @@ button.breadcrumb-current { max-width: 260px; }
═══════════════════════════════════════════════════════════ */
/* Base = viewport du contenu ; les vues tabulaires larges (bases de
données) s'alignent à gauche, au ras de la marge, à la même largeur
que Library (1100px). */
données) prennent la LARGEUR DU DOCUMENT (les colonnes dictent le scroll
horizontal), alignées à gauche au ras de la marge. */
.database-table-container {
width: 100%;
max-width: 1100px;
margin: 0;
/* var(--space-lg) = padding de .topbar : le bord gauche du tableau
s'aligne sur le titre du fil d'ariane, au ras de la marge. */
+8
View File
@@ -207,6 +207,14 @@
}
.mt-card:active { cursor: grabbing; opacity: .7; }
.mt-card-title { font-weight: 500; }
/* Bouton « Open » en overlay au survol de la tuile (la carte est une
colonne flex : une place en flux déformerait la tuile). */
.mt-card { position: relative; }
.mt-card .mt-open-btn {
position: absolute; top: 6px; right: 6px; margin-left: 0;
}
.mt-card:hover .mt-hover-only,
.mt-card:focus-within .mt-hover-only { opacity: 1; }
.mt-card-due,
.mt-card-src { font-size: 11px; color: var(--text-dim, #999); }
.mt-card-src { opacity: .8; }
+34
View File
@@ -0,0 +1,34 @@
/* ═══ Panel skeleton shared by /settings and /help (Notion-style modal) ═══ */
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
.settings-panel{position:relative;display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
.settings-nav-item:hover{background:var(--bg-hover);}
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;text-decoration:none;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Mobile hamburger for the panel nav — hidden on desktop */
.settings-menu-btn{display:none;position:absolute;top:8px;left:8px;z-index:20;width:40px;height:40px;border:none;border-radius:8px;background:var(--bg-tertiary);color:var(--text);align-items:center;justify-content:center;cursor:pointer;}
.settings-nav-backdrop{display:none;}
/* ── Mobile: nav becomes a left side-navigation drawer (hamburger opens it) ── */
@media (max-width:768px){
.settings-overlay{background:var(--bg-primary);backdrop-filter:none;}
.settings-panel{flex-direction:column;width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;border:none;border-radius:0;box-shadow:none;}
.settings-menu-btn{display:flex;}
.settings-nav{position:fixed;top:0;left:0;bottom:0;width:260px;max-width:80vw;min-width:0;z-index:600;transform:translateX(-100%);transition:transform .2s cubic-bezier(0.4,0,0.2,1);border-right:1px solid var(--border);box-shadow:4px 0 24px rgba(0,0,0,.4);padding-top:8px;}
.settings-nav.nav-open{transform:translateX(0);}
.settings-nav-item{padding:10px 16px;}
.settings-nav-backdrop{display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:599;}
.settings-content{padding:56px 16px 16px;}
.settings-close{top:8px;right:8px;width:44px;height:44px;background:var(--bg-tertiary);}
.setting-row{flex-wrap:wrap;}
.setting-control{margin-left:0;width:100%;}
.prov-grid{grid-template-columns:1fr;}
.avatar-section{gap:10px;}
}
+714 -6
View File
@@ -32,6 +32,30 @@
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack',
template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'}
];
// ── Menu + (v7.51.0) : sections à deux niveaux. `soon` = section pas encore
// livrée → affichée mais non cliquable (le menu ne promet rien que le code ne fait).
var FD_PLUS_MENU = [
{key:'files', icon:'📎', label:'Ajouter des fichiers ou répertoires',
sub:'Rechercher ou parcourir le workspace', action:'files', chev:true},
{key:'skills', icon:'✨', label:'Compétences-skills',
sub:'Deep research, Skill-creator… · Gérer · Parcourir', action:'skills', chev:true},
{key:'connectors', icon:'🔌', label:'Connecteurs',
sub:'Parcourir les connecteurs · ajouter un personnalisé', action:'connectors', chev:true},
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
{key:'plugins', icon:'🧩', label:'Add plugins',
sub:'Modules on/off — effet réel : routes, UI, outils', action:'plugins', chev:true},
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
sub:'Résumés des échanges injectés à chaque run', action:'memory'}
];
var FD_PLUS_FILES = [
{key:'f-search', icon:'🔍', label:'Rechercher…',
sub:'Documents, bases, pages par nom', action:'search', chev:true},
{key:'f-browse', icon:'📁', label:'Parcourir…',
sub:'Dossiers et fichiers du workspace', action:'browse', chev:true}
];
// Libellés affichés des skills builtin (le slug /commande reste inchangé).
var FD_SKILL_ALIAS = {'research': 'Deep research', 'create-new-skill': 'Skill-creator'};
function agentPanel(){
return {
@@ -45,6 +69,14 @@
contextChips: [], skills: [], gallery: [],
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
plusOpen: false, plusSection: 'root', plusFocus: -1, plusLoading: false,
plusBrowse: [], plusCrumb: [],
skillForm: {id: null, name: '', description: '', prompt_template: ''},
galleryFilter: '',
canvases: [], canvas: null,
connectors: [], connector: null, plugins: [],
connectorForm: {name: '', url: '', secret: '', kind: 'custom',
auth: 'bearer', hint: ''},
cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null,
_livePh: null,
@@ -55,6 +87,18 @@
init(){
var self = this;
var el = document.getElementById('fd-agent-panel');
// Retour du flux OAuth (Google / M365) : signaler le résultat puis nettoyer l'URL.
try{
var qs = new URLSearchParams(window.location.search);
if(qs.get('oauth') || qs.get('oauth_error')){
this.toast(qs.get('oauth_error')
? 'Connexion du connecteur refusée : ' + qs.get('oauth_error')
: 'Connecteur connecté.', !!qs.get('oauth_error'));
qs.delete('oauth'); qs.delete('oauth_error');
window.history.replaceState(null, '', window.location.pathname
+ (qs.toString() ? '?' + qs.toString() : '') + window.location.hash);
}
}catch{ /* volontaire */ }
function applyState(){
if(el){ if(self.open){ el.classList.add('open'); el.setAttribute('aria-hidden','false'); } else { el.classList.remove('open'); el.setAttribute('aria-hidden','true'); } }
@@ -469,7 +513,8 @@
var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨',
label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id};
this.contextChips.push(pin);
this.clearComposer();
// Ne jamais effacer un message déjà tapé (le token s'ajoute à la fin).
if(!this.composerText().trim()) this.clearComposer();
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
},
@@ -616,7 +661,8 @@
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
var conv = {id:d.id, title:d.title, memory_enabled:d.memory_enabled,
updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
@@ -633,7 +679,8 @@
self._ensuring = fetch('/api/agent/conversations', {
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
var conv = {id:d.id, title:d.title, memory_enabled:d.memory_enabled,
updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
@@ -1070,12 +1117,659 @@
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
},
// ── Mentions & contexte épinglé (@ / +) ──
// ── Menu + (sections) & mentions (@ / recherche) ──
toggleAddPicker(){
if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; }
this.openMentionPicker('+');
if(this.mentionOpen){
this.mentionOpen = false; this.mentionQuery = '';
this._mentionFrom = null; this._atNode = null;
}
if(this.plusOpen){ this.closePlus(); return; }
this.plusOpen = true;
this.plusSection = 'root';
this.plusFocus = 0;
},
closePlus(){
this.plusOpen = false;
this.plusSection = 'root';
this.plusCrumb = [];
this.plusBrowse = [];
this.plusFocus = -1;
},
get plusTitle(){
var s = this.plusSection;
if(s === 'files') return 'Fichiers & répertoires';
if(s === 'browse') return 'Parcourir le workspace';
if(s === 'skills') return 'Compétences-skills';
if(s === 'skills-manage') return 'Gérer les compétences';
if(s === 'skills-gallery') return 'Parcourir les compétences';
if(s === 'skills-edit') return this.skillForm.id ? 'Modifier le skill' : 'Nouveau skill';
if(s === 'connectors') return 'Connecteurs';
if(s === 'connector') return this.connector ? this.connector.name : 'Connecteur';
if(s === 'connector-new') return 'Ajouter un connecteur';
if(s === 'canvases') return 'Design System – Canevas';
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
if(s === 'plugins') return 'Add plugins';
return 'Ajouter au contexte';
},
get plusItems(){
var s = this.plusSection;
if(s === 'files') return FD_PLUS_FILES;
if(s === 'browse') return this.plusBrowse;
if(s === 'skills') return this._skillsItems();
if(s === 'skills-manage') return this._manageItems();
if(s === 'skills-gallery') return this._galleryItems();
if(s === 'skills-edit') return []; // formulaire, pas de liste
if(s === 'connectors') return this._connectorItems();
if(s === 'connector') return this._connectorActionItems();
if(s === 'connector-new') return [];
if(s === 'canvases') return this._canvasItems();
if(s === 'canvas') return this._canvasActionItems();
if(s === 'plugins') return this._pluginItems();
if(s === 'root'){
// l'entrée Mémoire reflète l'état réel du toggle de la conversation
var on = this.memoryOn;
return FD_PLUS_MENU.map(function(it){
if(it.key !== 'memory') return it;
return {key:'memory', icon: on ? '🧠' : '💭', label:'Mémoire (on/off)',
sub: on ? 'Activée — résumés des échanges injectés à chaque run'
: 'Désactivée — contexte strict du run courant',
action:'memory'};
});
}
return FD_PLUS_MENU;
},
// État du toggle mémoire de la conversation courante (défaut : activée).
get memoryOn(){
if(!this.currentConv) return true;
var v = this.currentConv.memory_enabled;
return v === undefined || v === null ? true : !!Number(v);
},
// Bascule le toggle mémoire (PATCH /api/agent/conversations/{id}).
toggleMemory(){
var self = this, conv = this.currentConv;
if(!conv || !conv.id){
this.toast('Ouvrez une conversation pour activer la mémoire.', true);
return;
}
var next = this.memoryOn ? 0 : 1;
fetch('/api/agent/conversations/' + conv.id, {
method: 'PATCH', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({memory_enabled: next})
}).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Bascule impossible.', true); return; }
conv.memory_enabled = next;
self.toast(next ? 'Mémoire activée — résumés injectés à chaque run.'
: 'Mémoire désactivée — contexte strict du run courant.');
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
},
// Sous-menu Compétences : builtin (alias affiché) + installés, puis les 2 actions.
_skillsItems(){
var out = [];
FD_SKILLS.forEach(function(c){
var slug = String(c.cmd).replace(/^\//, '');
out.push({key:'bi-'+slug, icon:c.icon, label:FD_SKILL_ALIAS[slug] || c.cmd,
sub:c.desc, action:'skill',
suggestion:{key:'bi-'+slug, cmd:c.cmd, icon:c.icon, template:c.template, _type:'builtin'}});
});
(this.skills || []).forEach(function(sk){
out.push({key:'db-'+sk.id, icon:'✨', label:sk.name,
sub:sk.description || 'Skill enregistré', action:'skill',
suggestion:{key:'db-'+sk.id, cmd:'/'+sk.name, icon:'✨', _type:'db', skillId:sk.id}});
});
out.push({key:'sep-actions', _sep:'Gérer / parcourir'});
out.push({key:'a-manage', icon:'🛠️', label:'Gérer les compétences',
sub:'Créer, modifier, supprimer, exporter / importer', action:'skills-manage', chev:true});
out.push({key:'a-browse', icon:'🗂️', label:'Parcourir les compétences',
sub:'Galerie de presets installables en 1 clic', action:'skills-gallery', chev:true});
return out;
},
// Sous-section Gérer : liste des skills + création + import.
_manageItems(){
var out = [];
(this.skills || []).forEach(function(sk){
out.push({key:'m-'+sk.id, icon:'✨', label:sk.name,
sub:sk.description || 'Cliquer pour modifier', action:'skill-edit', chev:true, skillId:sk.id});
});
if(!out.length) out.push({key:'m-none', _sep:'Aucun skill enregistré'});
out.push({key:'m-new', icon:'+', label:'Nouveau skill',
sub:'Nom, description, prompt', action:'skill-new', chev:true});
out.push({key:'m-sep', _sep:'Import'});
out.push({key:'m-import', icon:'📥', label:'Importer un skill (JSON)',
sub:'Document exporté depuis une autre instance', action:'skill-import'});
return out;
},
// Sous-section Parcourir : presets de la galerie, filtrables.
_galleryItems(){
var f = String(this.galleryFilter || '').toLowerCase();
var out = [];
(this.gallery || []).forEach(function(g){
var hay = (String(g.name || '') + ' ' + (g.description || '') + ' ' + (g.slug || '')).toLowerCase();
if(f && hay.indexOf(f) < 0) return;
out.push({key:'g-'+g.slug, icon:g.icon || '📦', label:g.name,
sub:g.description || 'Preset de la galerie', action:'skill-install',
slug:g.slug, skillName:g.name});
});
if(!out.length) out.push({key:'g-none', _sep: f ? 'Aucun résultat' : 'Galerie vide'});
return out;
},
// ── Connecteurs (v7.55.0) ──
fetchConnectors(){
var self = this;
fetch('/api/agent/connectors').then(function(r){ return r.json(); })
.then(function(d){
self.connectors = d.connectors || [];
// garde la fiche ouverte alignée sur la liste rafraîchie
var sel = self.connector;
if(sel){
var found = null;
self.connectors.forEach(function(c){
if((sel.id != null && c.id === sel.id) || (sel.id == null && c.kind === sel.kind)) found = c;
});
self.connector = found;
}
}).catch(function(){ self.connectors = []; });
},
_connectorStatus(c){
var mark = c.status === 'ok' ? '✓ ' : (c.status === 'error' ? '✗ '
: (c.status === 'missing' ? '⚠ ' : '? '));
var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : '';
return mark + (c.detail || c.status) + tools + (c.enabled ? '' : ' · désactivé');
},
// ── Plugins de l'instance (v7.58.0) ──
fetchPlugins(){
var self = this;
fetch('/api/agent/plugins').then(function(r){ return r.json(); })
.then(function(d){ self.plugins = (d && d.plugins) || []; })
.catch(function(){ self.plugins = []; });
},
_pluginItems(){
var out = (this.plugins || []).map(function(pl){
return {key:'pl-' + pl.slug, icon: pl.enabled ? '✅' : '⛔',
label: pl.name,
sub: (pl.enabled ? 'Activé' : 'Désactivé') + ' — ' + pl.description,
action:'plugin-toggle', slug: pl.slug};
});
if(!out.length) out.push({key:'pl-none', _sep:'Aucun plugin'});
out.push({key:'pl-hint', _sep:
'OFF = routes refusées, UI masquée, outils retirés'});
return out;
},
pluginToggle(slug){
var self = this;
var pl = (this.plugins || []).filter(function(x){ return x.slug === slug; })[0];
if(!pl) return;
fetch('/api/agent/plugins/' + encodeURIComponent(slug), {
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({enabled: !pl.enabled})
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Bascule impossible.', true); return; }
self.toast(res.d.enabled ? 'Plugin activé.' : 'Plugin désactivé.');
self.fetchPlugins();
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
},
_connectorItems(){
var self = this;
var out = (this.connectors || []).map(function(c){
return {key:'cn-' + (c.id != null ? c.id : c.kind), icon: c.builtin ? '🌐' : '🔌',
label: c.name, sub: self._connectorStatus(c),
action:'connector', connector:c, chev:true};
});
if(!out.length) out.push({key:'cn-none', _sep:'Aucun connecteur'});
out.push({key:'cn-sep', _sep:'Ajout'});
out.push({key:'cn-new', icon:'+', label:'Ajouter un connecteur personnalisé',
sub:'URL publique + clé API', action:'connector-new', chev:true});
return out;
},
_connectorActionItems(){
var c = this.connector;
if(!c) return [];
var items = [
{key:'cn-meta', _sep: (c.builtin ? 'Connecteur natif · ' : 'Connecteur · ') + c.name},
{key:'cn-probe', icon:'↻', label:'Tester le connecteur',
sub:'Appel de contrôle — status + détail', action:'connector-probe'}
];
if(c.oauth){
var connected = c.status === 'ok';
items.push({key:'cn-oauth', icon: connected ? '🔓' : '🔑',
label: connected ? 'Déconnecter' : 'Se connecter',
sub: connected ? 'Retire les autorisations de ce compte'
: 'Ouvre la page de connexion du fournisseur',
action: connected ? 'connector-disconnect' : 'connector-connect'});
}
if(!c.builtin){
items.push({key:'cn-toggle', icon: c.enabled ? '⏸' : '▶',
label: c.enabled ? 'Désactiver' : 'Activer',
sub:'Désactivé = lecture refusée par l\u2019outil', action:'connector-toggle'});
items.push({key:'cn-del', icon:'🗑️', label:'Supprimer le connecteur',
sub:'Supprime aussi sa clé', action:'connector-delete'});
}
return items;
},
_connectorRef(c){
c = c || this.connector;
return c ? (c.id != null ? String(c.id) : c.kind) : '';
},
connectorProbe(){
var self = this;
fetch('/api/agent/connectors/probe', {
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({connector: this._connectorRef()})
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Test impossible.', true); return; }
self.toast('Connecteur : ' + res.d.status + ' — ' + (res.d.detail || ''));
self.fetchConnectors();
}).catch(function(){ self.toast('Test impossible (réseau).', true); });
},
connectorToggle(){
var self = this, c = this.connector;
if(!c || c.builtin || c.id == null) return;
fetch('/api/agent/connectors/' + c.id, {
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({enabled: !c.enabled})
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast('Bascule impossible.', true); return; }
self.toast(res.d.enabled ? 'Connecteur activé.' : 'Connecteur désactivé.');
self.fetchConnectors();
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
},
// ── Connexion OAuth Google / M365 (v7.56.0) ──
connectorConnect(){
var self = this, c = this.connector;
if(!c || !c.oauth) return;
fetch('/api/agent/connectors/oauth/' + c.kind + '/authorize', {
method:'POST', headers:{'X-CSRF-Token': getCsrf()}
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Connexion impossible.', true); return; }
window.location.href = res.d.url; // → fournisseur → callback → retour ici
}).catch(function(){ self.toast('Connexion impossible (réseau).', true); });
},
connectorDisconnect(){
var self = this, c = this.connector;
if(!c || !c.oauth) return;
fetch('/api/agent/connectors/oauth/' + c.kind + '/disconnect', {
method:'POST', headers:{'X-CSRF-Token': getCsrf()}
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast('Déconnexion impossible.', true); return; }
self.toast('Connecteur déconnecté.');
self.fetchConnectors();
}).catch(function(){ self.toast('Déconnexion impossible (réseau).', true); });
},
connectorDelete(){
var self = this, c = this.connector;
if(!c || c.builtin || c.id == null) return;
if(!window.confirm('Supprimer le connecteur « ' + c.name + ' » ?')) return;
fetch('/api/agent/connectors/' + c.id, {method:'DELETE', headers:{'X-CSRF-Token': getCsrf()}})
.then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast('Suppression impossible.', true); return; }
self.toast('Connecteur supprimé.');
self.connector = null;
self.plusSection = 'connectors'; self.plusFocus = 0;
self.fetchConnectors();
}).catch(function(){ self.toast('Suppression impossible (réseau).', true); });
},
// Type choisi dans le formulaire → URL / schéma d'auth / aide pré-remplis.
connectorPreset(){
var f = this.connectorForm;
var presets = {
custom: {url:'', auth:'bearer', hint:'URL publique de votre API (hôtes privés refusés)'},
discord: {url:'https://discord.com/api/v10', auth:'bot',
hint:'Discord Developers → Bot Token (jeton en Authorization)'},
telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none',
hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'},
mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'}
};
var p = presets[f.kind] || presets.custom;
f.url = p.url; f.auth = p.auth; f.hint = p.hint;
},
connectorCreate(){
var self = this, f = this.connectorForm;
if(!(f.name || '').trim() || !(f.url || '').trim()){
this.toast('Nom et URL requis.', true); return;
}
fetch('/api/agent/connectors', {
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: f.name, url: f.url, secret: f.secret || '',
kind: f.kind || 'custom', auth: f.auth || 'bearer'})
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Création impossible.', true); return; }
self.toast('Connecteur « ' + res.d.name + ' » ajouté.');
self.connectorForm = {name:'', url:'', secret:''};
self.plusSection = 'connectors'; self.plusFocus = 0;
self.fetchConnectors();
}).catch(function(){ self.toast('Création impossible (réseau).', true); });
},
// ── Design System – Canevas (v7.53.0) ──
fetchCanvases(){
var self = this;
fetch('/board/api/page-templates').then(function(r){ return r.json(); })
.then(function(d){ self.canvases = d.templates || []; })
.catch(function(){ self.canvases = []; });
},
_canvasItems(){
return (this.canvases || []).map(function(t){
return {key: 'cv-' + (t.builtin ? ('b-' + t.key) : ('u-' + t.id)),
icon: t.icon || '📄', label: t.name,
sub: (t.description || (t.builtin ? 'Canevas intégré' : 'Canevas personnel')),
action: 'canvas', canvas: t, chev: true};
});
},
_canvasActionItems(){
var c = this.canvas;
if(!c) return [];
var pageId = this._openPageId();
return [
{key:'cv-meta', _sep: (c.builtin ? 'Canevas intégré · ' : 'Canevas personnel · ') + c.name},
{key:'cv-create', icon:'+', label:'Créer une page à partir du canevas',
sub:'Nouvelle page dans le workspace courant', action:'canvas-create'},
{key:'cv-insert', icon:'⇩', label:'Insérer dans le document ouvert',
sub: pageId ? 'Ajoute les blocs en fin de page' : 'Aucun document éditable ouvert',
action:'canvas-insert', disabled: !pageId},
{key:'cv-save', icon:'⭐', label:'Enregistrer le document ouvert comme canevas',
sub: pageId ? 'Réutilisable depuis ce menu' : 'Aucun document ouvert',
action:'canvas-save', disabled: !pageId}
];
},
// Id de la page ouverte dans l'éditeur (refId du contexte, sinon l'URL).
_openPageId(){
try{
var c = window.FlowDeckCtx && window.FlowDeckCtx.current();
if(c && c.kind === 'page' && c.refId != null && /^\d+$/.test(String(c.refId))) return String(c.refId);
}catch{ /* volontaire */ }
var m = /\/pages\/(\d+)/.exec(window.location.pathname);
return m ? m[1] : null;
},
_canvasUrl(suffix, t){
t = t || this.canvas;
return t.builtin
? ('/board/api/page-templates/0/' + suffix + '?key=' + encodeURIComponent(t.key))
: ('/board/api/page-templates/' + t.id + '/' + suffix);
},
canvasCreate(){
var self = this, c = this.canvas;
if(!c) return;
// le builtin se choisit par `key` DANS LE BODY (route /use), pas en query
fetch(this._canvasUrl('use'), {
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify(c.builtin ? {key: c.key} : {})
}).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Création impossible.', true); return; }
self.toast('Page créée : « ' + ((res.d && res.d.title) || c.name) + ' » (Workspace).');
self.plusSection = 'canvases'; self.plusFocus = 0;
}).catch(function(){ self.toast('Création impossible (réseau).', true); });
},
canvasInsert(){
var self = this, c = this.canvas;
if(!c) return;
fetch(this._canvasUrl('blocks')).then(function(r){ return r.json(); })
.then(function(d){
var E = window.E;
if(!E || !E.blocks || !d || !Array.isArray(d.blocks)){
self.toast('Aucun document éditable ouvert.', true); return;
}
if(window.ensureBlockIds) window.ensureBlockIds(d.blocks);
try{
if(E.sync) E.sync();
var empty = E.blocks.length === 1 && E.blocks[0].type === 'paragraph'
&& !(E.blocks[0].content || '').trim();
E.blocks = empty ? d.blocks : E.blocks.concat(d.blocks);
E.dirty = true;
if(E.autoSave) E.autoSave();
if(E.render) E.render();
self.toast('Canevas inséré dans le document.');
}catch{ self.toast('Insertion impossible.', true); }
}).catch(function(){ self.toast('Lecture du canevas impossible.', true); });
},
canvasSave(){
var self = this, c = this.canvas;
var pageId = this._openPageId();
if(!c || !pageId) return;
var def = (this.activeContext && this.activeContext.label) || c.name || 'Mon canevas';
var name = window.prompt('Nom du canevas à enregistrer :', def);
if(!name || !name.trim()) return;
name = name.trim();
fetch('/board/api/page-templates', {
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: name, page_id: Number(pageId)})
}).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Enregistrement impossible.', true); return; }
self.toast('Canevas enregistré : « ' + name + ' »');
self.fetchCanvases();
}).catch(function(){ self.toast('Enregistrement impossible (réseau).', true); });
},
_plusSelectable(){
var out = [];
this.plusItems.forEach(function(it, i){ if(!it.disabled && !it._sep) out.push(i); });
return out;
},
movePlusFocus(delta){
var sel = this._plusSelectable();
if(!sel.length) return;
var idx = sel.indexOf(this.plusFocus);
if(idx < 0) idx = delta > 0 ? -1 : 0;
this.plusFocus = sel[(idx + delta + sel.length) % sel.length];
this._scrollPlusItem();
},
_scrollPlusItem(){
var menu = this.$refs && this.$refs.plusMenu;
if(!menu) return;
var el = menu.querySelector('.fd-mention-item.focus');
if(el && el.scrollIntoView){ menu.scrollTop = el.offsetTop - menu.clientHeight / 2 + el.clientHeight / 2; }
},
plusAction(it){
if(!it || it.disabled || it._sep) return;
if(it.action === 'files'){ this.plusSection = 'files'; this.plusFocus = 0; return; }
if(it.action === 'search'){ this.closePlus(); this.openMentionPicker('+'); return; }
if(it.action === 'browse'){
this.plusSection = 'browse'; this.plusCrumb = []; this.plusLoadBrowse(null); return;
}
// ── Compétences (v7.52.0) ──
if(it.action === 'memory'){ this.toggleMemory(); return; }
if(it.action === 'skills'){ this.plusSection = 'skills'; this.plusFocus = 0; return; }
if(it.action === 'skill'){ this.closePlus(); this.useSuggestion(it.suggestion); return; }
if(it.action === 'skills-manage'){
this.plusSection = 'skills-manage'; this.plusFocus = 0; return;
}
if(it.action === 'skills-gallery'){
this.galleryFilter = ''; this.fetchSkills();
this.plusSection = 'skills-gallery'; this.plusFocus = 0; return;
}
if(it.action === 'skill-new'){
this.skillForm = {id:null, name:'', description:'', prompt_template:''};
this.plusSection = 'skills-edit'; this.plusFocus = -1; return;
}
if(it.action === 'skill-edit'){
var sk = null, wanted = it.skillId;
(this.skills || []).forEach(function(x){ if(x.id === wanted) sk = x; });
if(!sk) return;
this.skillForm = {id: sk.id, name: sk.name, description: sk.description || '',
prompt_template: sk.prompt_template || ''};
this.plusSection = 'skills-edit'; this.plusFocus = -1; return;
}
if(it.action === 'skill-install'){
this.installGallerySkill(it.slug, it.icon, it.skillName); return;
}
if(it.action === 'skill-import'){
if(this.$refs.skillImport) this.$refs.skillImport.click();
return;
}
// ── Connecteurs (v7.55.0) ──
if(it.action === 'connectors'){
this.fetchConnectors(); this.plusSection = 'connectors'; this.plusFocus = 0; return;
}
if(it.action === 'connector'){
this.connector = it.connector; this.plusSection = 'connector'; this.plusFocus = 0; return;
}
if(it.action === 'connector-new'){
this.connectorForm = {name:'', url:'', secret:'', kind:'custom',
auth:'bearer', hint:''};
this.plusSection = 'connector-new'; this.plusFocus = -1; return;
}
if(it.action === 'plugins'){
this.fetchPlugins(); this.plusSection = 'plugins'; this.plusFocus = 0; return;
}
if(it.action === 'plugin-toggle'){ this.pluginToggle(it.slug); return; }
if(it.action === 'connector-probe'){ this.connectorProbe(); return; }
if(it.action === 'connector-toggle'){ this.connectorToggle(); return; }
if(it.action === 'connector-delete'){ this.connectorDelete(); return; }
if(it.action === 'connector-connect'){ this.connectorConnect(); return; }
if(it.action === 'connector-disconnect'){ this.connectorDisconnect(); return; }
// ── Design System – Canevas (v7.53.0) ──
if(it.action === 'canvases'){
this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return;
}
if(it.action === 'canvas'){
this.canvas = it.canvas; this.plusSection = 'canvas'; this.plusFocus = 0; return;
}
if(it.action === 'canvas-create'){ this.canvasCreate(); return; }
if(it.action === 'canvas-insert'){ this.canvasInsert(); return; }
if(it.action === 'canvas-save'){ this.canvasSave(); return; }
if(it._folder){ // entrer dans un sous-dossier
this.plusCrumb.push({id: it._folder, name: it.label});
this.plusLoadBrowse(it._folder);
return;
}
if(it.token){ this._mentionFrom = null; this._atNode = null; this.closePlus(); this.pickMention(it); return; }
this.closePlus(); // sections futures (phases 2-8)
},
plusBack(){
var sec = this.plusSection;
if(sec === 'browse' && this.plusCrumb.length){
this.plusCrumb.pop();
this.plusLoadBrowse(this.plusCrumb.length ? this.plusCrumb[this.plusCrumb.length - 1].id : null);
return;
}
if(sec === 'skills-edit'){ this.plusSection = 'skills-manage'; this.plusFocus = 0; return; }
if(sec === 'skills-manage' || sec === 'skills-gallery'){
this.plusSection = 'skills'; this.plusFocus = 0; return;
}
if(sec === 'canvas'){ this.plusSection = 'canvases'; this.plusFocus = 0; return; }
if(sec === 'connector' || sec === 'connector-new'){
this.plusSection = 'connectors'; this.plusFocus = 0; return;
}
this.plusSection = 'root';
this.plusFocus = 0;
},
plusGoto(i){ // fil d'Ariane : -1 = racine
this.plusCrumb = i < 0 ? [] : this.plusCrumb.slice(0, i + 1);
this.plusLoadBrowse(i < 0 ? null : this.plusCrumb[this.plusCrumb.length - 1].id);
},
plusLoadBrowse(parentId){
// Un niveau par appel : /api/nav/menu (dossier = icon 'folder').
var self = this;
this.plusLoading = true;
fetch('/api/nav/menu' + (parentId ? ('?parent_id=' + parentId) : ''))
.then(function(r){ return r.json(); })
.then(function(d){
var items = [];
// icon de /api/nav/menu = nom SVG ('folder'|'edit'|'image'|'file') → emoji
var ICONS = {folder:'📁', edit:'📄', image:'🖼️', file:'📎'};
(d.items || []).forEach(function(x){
var isFolder = x.icon === 'folder';
items.push({
key: 'br-' + x.id, icon: ICONS[x.icon] || '📄',
label: x.name, sub: isFolder ? 'Dossier — ouvrir' : 'Document — épingler',
token: (isFolder ? 'folder:' : 'document:') + x.id,
type: isFolder ? 'folder' : 'document', id: x.id,
_folder: isFolder ? x.id : null
});
});
if(self.plusCrumb.length){
var cur = self.plusCrumb[self.plusCrumb.length - 1];
items.unshift({key: 'pin', icon: '📌',
label: 'Épingler le dossier « ' + cur.name + ' »',
sub: 'Ajoute le dossier entier au contexte',
token: 'folder:' + cur.id, type: 'folder', id: cur.id});
}
self.plusBrowse = items;
self.plusLoading = false;
self.plusFocus = items.length ? 0 : -1;
self.$nextTick(function(){ self._scrollPlusItem(); });
})
.catch(function(){ self.plusBrowse = []; self.plusLoading = false; self.plusFocus = -1; });
},
// ── Gérer les compétences : CRUD / export / import (v7.52.0) ──
saveSkill(){
var self = this, f = this.skillForm;
if(!(f.name || '').trim()){ this.toast('Le nom est requis.', true); return; }
fetch(f.id ? '/api/agent/skills/' + f.id : '/api/agent/skills', {
method: f.id ? 'PATCH' : 'POST',
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: f.name, description: f.description || '',
prompt_template: f.prompt_template || ''})
}).then(function(r){
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
}).then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Enregistrement impossible.', true); return; }
self.toast('Skill enregistré.');
self.fetchSkills();
self.plusSection = 'skills-manage'; self.plusFocus = 0;
}).catch(function(){ self.toast('Enregistrement impossible (réseau).', true); });
},
deleteSkill(){
var self = this, f = this.skillForm;
if(!f.id) return;
if(!window.confirm('Supprimer le skill « ' + f.name + ' » ?')) return;
fetch('/api/agent/skills/' + f.id, {method: 'DELETE', headers: {'X-CSRF-Token': getCsrf()}})
.then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Suppression impossible.', true); return; }
self.toast('Skill supprimé.');
// le chip épinglé ne doit pas survivre au skill
self.contextChips = self.contextChips.filter(function(ch){
return !(ch.kind === 'skill' && ch.skillId === f.id);
});
self.skillForm = {id: null, name: '', description: '', prompt_template: ''};
self.fetchSkills();
self.plusSection = 'skills-manage'; self.plusFocus = 0;
}).catch(function(){ self.toast('Suppression impossible (réseau).', true); });
},
exportSkill(){
var self = this, f = this.skillForm;
if(!f.id) return;
fetch('/api/agent/skills/' + f.id + '/export')
.then(function(r){ return r.json(); })
.then(function(doc){
var blob = new Blob([JSON.stringify(doc, null, 2)], {type: 'application/json'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = String((doc && doc.name) || 'skill').replace(/[^\w.-]+/g, '-') + '.json';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(function(){ URL.revokeObjectURL(a.href); }, 1000);
}).catch(function(){ self.toast('Export impossible.', true); });
},
importSkillFile(ev){
var self = this;
var input = ev.target, file = input.files && input.files[0];
if(!file) return;
file.text().then(function(txt){
var payload;
try{ payload = JSON.parse(txt); }
catch{ self.toast('Fichier JSON invalide.', true); return; }
return fetch('/api/agent/skills/import', {
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({payload: payload, overwrite: true})
}).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Import impossible.', true); return; }
self.toast('Skill importé : ' + ((res.d && res.d.name) || '') + ' (pas de doublon).');
self.fetchSkills();
self.plusSection = 'skills-manage'; self.plusFocus = 0;
});
}).catch(function(){ self.toast('Lecture du fichier impossible.', true); })
.finally(function(){ input.value = ''; });
},
openMentionPicker(from){
this.plusOpen = false;
this.mentionOpen = true;
this._mentionFrom = from || '+';
this.mentionQuery = '';
@@ -1177,6 +1871,7 @@
onInput(){
this.resizeInput();
this.slashDismiss = false;
if(this.plusOpen) this.closePlus(); // tape = on écrit le message, le menu se referme
var text = this.composerText();
this.input = text;
var cmdNow = /^\s*\//.test(text);
@@ -1249,16 +1944,29 @@
}catch { /* volontaire */ }
}
if(key === 'Escape'){
if(this.plusOpen){ e.preventDefault(); e.stopPropagation(); this.closePlus(); return; }
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
return; // laisse fermer le panneau
}
if(key === 'ArrowDown' || key === 'ArrowUp'){
var delta = key === 'ArrowDown' ? 1 : -1;
if(this.plusOpen){ e.preventDefault(); this.movePlusFocus(delta); return; }
if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
return;
}
// Entrée / Tab avec le menu + ouvert
if(this.plusOpen){
if(key === 'Enter' || key === 'Tab'){
e.preventDefault();
var psel = this._plusSelectable();
if(!psel.length) return;
var pidx = psel.indexOf(this.plusFocus) >= 0 ? this.plusFocus : psel[0];
this.plusAction(this.plusItems[pidx]);
return;
}
}
// Entrée / Tab avec un menu ouvert (skills / mentions)
if(this.mentionOpen){
var mSel = this._selectableMentionItems();
+75 -61
View File
@@ -1283,72 +1283,86 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
}
// ── Side peek : panneau latéral des lignes de base (vanilla JS) ──
// Le panneau vit dans _database_table.html (page de base ET bases
// embarquées inline). Il doit être câblé SUR le document, jamais via
// container.__x : le conteneur n'a pas de x-data (les bindings Alpine
// du panneau restaient inertes — clic sur une ligne = rien).
var peekEl = document.getElementById('db-side-peek');
// Sans panneau (collections embarquées inline dans l'éditeur), le clic ne
// doit pas lever de ReferenceError : repli discret, comme l'ancien code.
if (!peekEl) window.openPageInSidePeek = function() {};
if (peekEl) {
// Le panneau vit dans _database_table.html (page de base). Une base
// EMBARQUÉE dans un document n'a pas ce markup : on le crée à la demande
// (comme mt-peek de My Tasks), sinon l'icône Open de fin de ligne était
// un no-op silencieux. Il doit être câblé SUR le document, jamais via
// container.__x : le conteneur n'a pas de x-data.
var PEEK_HTML =
'<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>' +
'<div class="db-side-peek-header">' +
'<button id="db-side-peek-close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>' +
'<div class="db-side-peek-title" id="db-side-peek-title"></div>' +
'<button id="db-side-peek-full" title="Open full page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>' +
'</div>' +
'<div class="db-side-peek-body">' +
'<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>' +
'<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>' +
'</div>';
function ensureDbPeek() {
var p = document.getElementById('db-side-peek');
if (p) return p;
p = document.createElement('div');
p.id = 'db-side-peek';
p.className = 'db-side-peek';
p.innerHTML = PEEK_HTML;
document.body.appendChild(p);
document.getElementById('db-side-peek-close').addEventListener('click', closeDbPeek);
document.getElementById('db-side-peek-full').addEventListener('click', function () {
var f = document.getElementById('db-side-peek-iframe');
// `dataset.page` et non `iframe.src` : le src porte `?embed=1`,
// la pleine page doit garder sa sidebar et sa barre.
if (f && f.dataset.page) window.location.href = '/pages/' + f.dataset.page;
});
if (window.fdWirePeekResize) window.fdWirePeekResize(p, p.querySelector('.peek-resize-handle'), closeDbPeek);
return p;
}
function closeDbPeek() {
var p = document.getElementById('db-side-peek');
if (!p) return;
p.classList.remove('open');
document.getElementById('db-side-peek-iframe').src='about:blank';
var t = document.getElementById('db-side-peek-title');
if (t) t.textContent = '';
}
window.openPageInSidePeek = function(pageId) {
if (!pageId) return;
var peekEl = ensureDbPeek();
var peekFrame = document.getElementById('db-side-peek-iframe');
var peekTitle = document.getElementById('db-side-peek-title');
var peekLoading = document.getElementById('db-side-peek-loading');
function closeDbPeek() {
peekEl.classList.remove('open');
document.getElementById('db-side-peek-iframe').src='about:blank';
if (peekTitle) { peekTitle.textContent = ''; }
}
window.openPageInSidePeek = function(pageId) {
if (!pageId) return;
peekEl.classList.add('open');
if (peekLoading) peekLoading.style.display = '';
var iframe = document.getElementById('db-side-peek-iframe');
if (iframe) iframe.src='about:blank';
if (peekTitle) peekTitle.textContent = '';
resolveRowContentPage(pageId).then(function(pid){
if (!peekEl.classList.contains('open')) return;
if (peekTitle) peekTitle.textContent = '';
if (peekLoading) peekLoading.style.display = 'none';
if (iframe) iframe.src = '/pages/'+pid;
})
.catch(function(){
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
// CSP d'Alpine les toasts ne s'affichent pas partout.
if (iframe) {
iframe.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
}
peekEl.classList.add('open');
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
});
};
var pc = document.getElementById('db-side-peek-close');
if (pc) pc.addEventListener('click', closeDbPeek);
var pf = document.getElementById('db-side-peek-full');
if (pf) pf.addEventListener('click', function() {
if (peekFrame && peekFrame.src && peekFrame.src !== 'about:blank') {
window.location.href = peekFrame.src;
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && peekEl.classList.contains('open')) closeDbPeek();
});
document.addEventListener('pointerdown', function(e) {
peekEl.classList.add('open');
if (peekLoading) peekLoading.style.display = '';
if (peekFrame) peekFrame.src = 'about:blank';
if (peekTitle) peekTitle.textContent = '';
resolveRowContentPage(pageId).then(function(pid){
if (!peekEl.classList.contains('open')) return;
if (peekEl.contains(e.target)) return;
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
closeDbPeek();
});
function wireDbPeekResize() {
if (window.fdWirePeekResize) {
window.fdWirePeekResize(peekEl, peekEl.querySelector('.peek-resize-handle'), closeDbPeek);
} else if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireDbPeekResize);
if (peekLoading) peekLoading.style.display = 'none';
// `?embed=1` : uniquement le document edite, pas l'habillage
// (sidebar + barre) — la pleine page reste via le bouton « full ».
if (peekFrame) { peekFrame.dataset.page = pid; peekFrame.src = '/pages/'+pid+'?embed=1'; }
})
.catch(function(){
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
// CSP d'Alpine les toasts ne s'affichent pas partout.
if (peekFrame) {
peekFrame.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
}
}
wireDbPeekResize();
}
peekEl.classList.add('open');
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
});
};
document.addEventListener('keydown', function(e) {
var p = document.getElementById('db-side-peek');
if (e.key === 'Escape' && p && p.classList.contains('open')) closeDbPeek();
});
document.addEventListener('pointerdown', function(e) {
var p = document.getElementById('db-side-peek');
if (!p || !p.classList.contains('open')) return;
if (p.contains(e.target)) return;
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
closeDbPeek();
});
function resolveRowContentPage(rowId){
return fetch('/db/pages/'+rowId+'/open/api')
+23
View File
@@ -0,0 +1,23 @@
if (!window.__fdHelpScriptLoaded) {
window.__fdHelpScriptLoaded = true;
// Alpine.data register (CSP build ne résout pas les globales window)
if (window.Alpine) { Alpine.data('helpInit', helpInit); fdRefreshXData('helpInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('helpInit', helpInit); });
function helpInit() {
return {
section: 'start',
navOpen: false,
closeNavOnMobile() { this.navOpen = false; },
// Bouton ✕ (haut droite, visible aussi en mobile) : ferme l'aide.
close() {
if (document.referrer && document.referrer !== location.href) { history.back(); }
else { location.href = '/workspaces'; }
},
go(sec) {
this.section = sec;
this.navOpen = false;
var c = document.querySelector('.settings-content');
if (c) c.scrollTop = 0;
},
};
}
}
+3 -3
View File
@@ -979,9 +979,9 @@ _renderIcon(item) {
if (loading) loading.style.display = '';
frame.onload = function () { if (loading) loading.style.display = 'none'; };
frame.src = 'about:blank';
// `/pages/{id}` sans `?embed=1` : le peek affiche le MÊME éditeur que la
// page pleine (sidebar, barre, actions compris), pas une variante.
frame.src = '/pages/' + item.id;
// `?embed=1` : le peek affiche SEULEMENT le document en mode édition
// (ni sidebar ni barre de navigation) — le cadre du panneau suffit.
frame.src = '/pages/' + item.id + '?embed=1';
},
closePeek() {
+5 -5
View File
@@ -395,12 +395,12 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
window.showToast('Failed to copy', 'error');
});
};
// Load the full-screen editor in the iframe (no `?embed=1`: the peek
// shows the same editor as the page itself). `about:blank` first, so
// opening another document really reloads the frame instead of keeping
// the previous one when the URL happens to be identical.
// Load ONLY the editable document in the iframe (`?embed=1`: no
// sidebar, no navbar). `about:blank` first, so opening another
// document really reloads the frame instead of keeping the previous
// one when the URL happens to be identical.
var iframe = document.getElementById('ws-preview-iframe');
if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id; }
if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id + '?embed=1'; }
// Show panel
panel.style.display = 'flex';
requestAnimationFrame(function() {
+12 -6
View File
@@ -152,9 +152,10 @@ function flowDeckMyTasks() {
f.style.display = '';
// `about:blank` d'abord : ouvrir une autre tâche doit REcharger
// l'éditeur, sinon l'URL identique laisse le document précédent
// affiché. `/pages/{id}` sans `?embed=1` = l'éditeur plein écran.
// affiché. `?embed=1` = le document en édition seule, sans
// sidebar ni barre de navigation (le cadre du panneau suffit).
f.src = 'about:blank';
f.src = '/pages/' + pid;
f.src = '/pages/' + pid + '?embed=1';
}).catch(function (e) {
if (loading) {
loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message;
@@ -389,6 +390,7 @@ function flowDeckMyTasks() {
h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' +
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
'<span class="mt-card-title">' + esc(t.title) + '</span>' +
openButton(t) +
(t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' +
esc(t.due) + '</span>' : '') +
'<span class="mt-card-src">' + esc(t.collection_icon) + ' ' +
@@ -878,17 +880,21 @@ function flowDeckMyTasks() {
var t = e.target;
if (t.id === 'mt-new-btn') { openNewModal(); return; }
if (t.dataset && t.dataset.openRow) {
var opener = t.closest ? t.closest('[data-open-row]') : null;
if (opener) {
// `closest` : dans le calendrier le bouton ne contient que son
// SVG — un clic sur l'icone donnait `e.target` = <svg> sans
// dataset, et le bouton ne faisait rien.
e.preventDefault();
// Le titre vient de l'attribut : il est disponible aussi bien dans
// le tableau que dans le calendrier ou la vue Jour.
var label = t.dataset.openTitle;
var label = opener.dataset.openTitle;
if (!label) {
var row = t.closest('.mt-row');
var row = opener.closest('.mt-row');
var cell = row && row.querySelector('.mt-title-text');
label = cell ? cell.textContent : '';
}
openRowContent(Number(t.dataset.openRow), label);
openRowContent(Number(opener.dataset.openRow), label);
return;
}
if (t.dataset && t.dataset.fixSource) {
+29 -8
View File
@@ -419,7 +419,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
// → framed result → "Insert below" → applyAIBlocks.
const AIC={_open:false,_idx:-1,_stage:'',_html:'',
const AIC={_open:false,_idx:-1,_stage:'',_html:'',_md:'',
ROOT:null,
mk(){ if(this.ROOT) return this.ROOT;
const d=document.createElement('div'); d.id='_aiC'; d.style.cssText='position:fixed;z-index:1200;width:420px;max-width:92vw;display:none';
@@ -432,7 +432,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
this.paint(); this.ROOT.style.display='block'; this._open=true; this._focusIngest();
document.addEventListener('mousedown',function onDoc(ev){ if(!self.ROOT.contains(ev.target)){ self.close(); document.removeEventListener('mousedown',onDoc); } });
},
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; if(window.E)window.E.dirty=true; this._stage=''; },
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; this._md=''; if(window.E)window.E.dirty=true; this._stage=''; },
_anchorRect(idx){ const E=window.E; if(!E||!E.blocks)return{left:Math.max(24,window.innerWidth/2-210),top:window.innerHeight/2,bottom:window.innerHeight/2,width:420};
const b=E.blocks[idx]; const el=E.getEl(b?b.id:null); if(el){const r=el.getBoundingClientRect();return r;} return {left:24,top:120,bottom:180,width:420}; },
_focusIngest(){ const i=this.ROOT.querySelector('.aici-input'); if(i){i.focus();} },
@@ -478,12 +478,17 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
const E=window.E; const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):'';
if(!(window.fdAgent&&window.fdAgent.generate)){ this._err('Agent IA non configuré'); return; }
window.fdAgent.generate(prompt, ctx).then(function(text){
self._html=self._mdToHtml(text||''); self._stage='result'; self.paint();
// On conserve le MARKDOWN source : c'est lui qui est inséré (md2b),
// pas le texte extrait du HTML rendu qui perd titres/listes/gras.
self._md=String(text||'');
self._html=self._mdToHtml(self._md); self._stage='result'; self.paint();
}).catch(function(err){ self._err((err&&err.message)?err.message:'Erreur'); });
},
_err(msg){ this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
_err(msg){ this._md=''; this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
insert(){ const E=window.E; if(!E||typeof E.applyAIBlocks!=='function')return; E.sync();
const result=this._resultText(); if(!result)return; E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
// Markdown source d'abord ; repli sur le texte visible (erreurs).
const result=String(this._md||'').trim()||this._resultText(); if(!result)return;
E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result'); if(!el)return ''; const r=document.createRange();r.selectNodeContents(el);return r.toString(); },
toast(m){ if(window.showToast)window.showToast(m,'info'); }
};
@@ -595,11 +600,19 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}${(b.style&&b.style.color)?';color:'+b.style.color:''}"`:((b.style&&(b.style.bgColor||b.style.color))?` style="${(b.style.bgColor?'background:'+b.style.bgColor+';':'')+(b.style.color?'color:'+b.style.color+';':'')}"`:'');
const bidAttr=b.type.startsWith('heading_')?` id="h-${(b.id||'').replace(/[^a-zA-Z0-9]/g,'')}"`:'';
// Numéro d'ordre : rang des blocs `numbered_list` contigus (1,2,3…) ;
// passé en attribut data-num → le CSS le rend hors du contenu éditable.
let numAttr='';
if(b.type==='numbered_list'&&idx>=0){
const bs=(window.E&&window.E.blocks)||[];
let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
numAttr=` data-num="${idx-s+1}"`;
}
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
return `<div class="block-wrapper" data-id="${b.id}">
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
${actions}
<${tag}${style}${bidAttr} class="${cls}">${inner}</${tag}>
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
</div>`;
}
@@ -1970,6 +1983,12 @@ applyAIBlocks(text){
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
// La requête peut être tapée DANS le bloc (« /numbered list ») si le
// focus n'a pas rejoint l'entrée du menu → on la répercute au filtre.
if(SM._in&&(e.key.length===1||e.key==='Backspace')){
const q=(el.textContent||'').replace(/^\s*\//,'');
if(SM._in.value!==q){SM._in.value=q;SM.flt();}
}
return;
}
if(window.AIAC&&window.AIAC.active){
@@ -2486,10 +2505,12 @@ applyAIBlocks(text){
else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));
else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));
else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));
// AVANT les puces : « - [ ] … » commence aussi par « - » →
// sinon la case à cocher de l'agent devenait une puce.
else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));
else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
else if(/^\d+[.)] /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+[.)] /,'')));
else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));
else bl.push(this.mkB('paragraph',t));
}return bl;},
+3
View File
@@ -18,6 +18,9 @@ function settingsInit() {
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
activeSection: 'account',
// Mobile side-navigation drawer for the settings panel (hamburger in topbar)
navOpen: false,
closeNavOnMobile() { this.navOpen = false; },
allTags: [],
newTagName: '',
newTagColor: '#787774',
+15
View File
@@ -89,6 +89,21 @@ def test_llm_client_providers_listed(client):
assert name in PROVIDERS
def test_mistral_defaults_are_tier_safe(client):
"""La clé Mistral plan basique → 403 « tier_not_allowed » sur mistral-large/pixtral-large.
Le modèle par défaut et le premier de la liste du test de connexion doivent
être des modèles servis par tous les plans."""
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
tier_blocked = {"mistral-large-latest", "pixtral-large-latest"}
assert PROVIDERS["mistral"][1] not in tier_blocked
assert PROVIDER_MODELS["mistral"][0] not in tier_blocked
# le fallback du modèle retiré (PROVIDERS default) est présent dans la liste
assert PROVIDERS["mistral"][1] in PROVIDER_MODELS["mistral"]
# et la liste des modèles Mistral est validée chat à chaque fetch (403 exclus)
from app.services.llm_config import _CHAT_VALIDATED_PROVIDERS
assert "mistral" in _CHAT_VALIDATED_PROVIDERS
def test_llm_client_openai_compatible_bases(client):
"""Providers that need an OpenAI-compatible surface must point at it.
+80 -16
View File
@@ -1388,13 +1388,35 @@ def test_library_peek_iframe_is_not_inside_the_x_if_template():
assert at > html.index("</template>"), "l'iframe doit être hors du x-if"
def test_library_peek_loads_the_full_screen_editor():
"""Le peek charge `/pages/{id}` SANS `?embed=1` : exactement l'éditeur
de la page entière (le mode embed masquait sidebar, barre et actions)."""
def test_library_peek_loads_the_embed_editor_only():
"""Le peek charge `/pages/{id}?embed=1` : le document en mode édition
SEUL — le cadre du panneau suffit, ni sidebar ni barre de navigation."""
body = _js_func(_library_js(), "openPeek(item) {")
assert "frame.src = '/pages/' + item.id" in body
assert "embed=1" not in _strip_js_comments(body)
assert "frame.src = '/pages/' + item.id + '?embed=1'" in body
def test_library_peek_body_has_no_inset_padding():
"""Le corps du peek de Library est nu (padding 0, overflow hidden) :
l'iframe doit remplir le panneau comme dans local-workspace, sans double
scrollbar ni bande grise autour du document."""
from pathlib import Path
html = Path("app/templates/library.html").read_text(encoding="utf-8")
rule = html.split(".peek-body{")[1].split("}")[0]
assert "padding:0" in rule
assert "overflow-y:auto" not in rule
def test_every_side_peek_loads_the_embed_editor_only():
"""Library, My Tasks, bases et workspace local : le peek ne montre QUE le
document éditable (`?embed=1`), jamais l'habillage pleine page."""
from pathlib import Path
for name in ("library.js", "my_tasks.js", "database_table.js",
"local_workspace.js"):
src = _strip_js_comments((Path("static/js") / name).read_text(encoding="utf-8"))
assert "?embed=1" in src, name
def test_library_peek_reloads_when_switching_documents():
@@ -1419,16 +1441,6 @@ def test_close_peek_unloads_the_editor_iframe():
assert "frame.onload = null" in body
def test_every_side_peek_loads_the_plain_page_url():
"""Library, My Tasks, bases et workspace local : même éditeur partout."""
from pathlib import Path
for name in ("library.js", "my_tasks.js", "database_table.js",
"local_workspace.js"):
src = (Path("static/js") / name).read_text(encoding="utf-8")
assert "?embed=1" not in _strip_js_comments(src), name
def test_calendar_offers_five_views():
"""Mois / Année / Semaine / Jour / Planning."""
js = _my_tasks_js()
@@ -1451,6 +1463,56 @@ def test_calendar_events_have_a_side_peek_icon():
assert "data-open-title=" in body
def test_open_buttons_resolve_with_closest_not_target():
"""Le gestionnaire de clic lit `data-open-row` via `closest` : les boutons
du calendrier et du Kanban ne contiennent que leur SVG — un clic sur
l'icone (e.target = <svg>, sans dataset) doit ouvrir le peek aussi."""
js = _my_tasks_js()
i = js.index("app.addEventListener('click'")
block = js[i:i + 900]
assert "closest('[data-open-row]')" in block
def test_kanban_cards_have_the_open_button():
"""Les tuiles du Kanban portent le même bouton « Open » que les lignes
du tableau (parité avec la page d'origine : même peek latéral)."""
body = _js_func(_my_tasks_js(), "function viewBoard(")
assert "openButton(t)" in body
# Le bouton est révélé au survol de la tuile (overlay, la carte est une
# colonne flex — pas de place en flux).
css = _my_tasks_css()
assert ".mt-card:hover .mt-hover-only" in css
def test_base_page_collection_renders_the_table_when_embedded():
"""`/pages/{id}?embed=1` sur une page de base garde le TABLEAU (template
collection) — le peek d'une base ouvre le tableau sans sidebar ni barre,
pas l'éditeur de blocs vide."""
from pathlib import Path
src = Path("app/routers/dashboard/pages_html.py").read_text(encoding="utf-8")
i = src.index('content_format") == "collection"')
branch = src[i:i + 220]
assert "not embed" not in branch.split("template")[0]
def test_embed_editor_can_render_inline_databases():
"""Le peek (`page_editor_embed.html`) doit charger database_table.js :
sans lui, une base embarquée restait bloquée à « Loading database… » et
l'icône Open de fin de ligne ne faisait rien. Le panneau #db-side-peek
est créé à la demande par database_table.js (markup unique)."""
from pathlib import Path
html = Path("app/templates/page_editor_embed.html").read_text(encoding="utf-8")
assert "_database_table_scripts.html" in html
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
assert "ensureDbPeek" in js
# Pas de second markup statique du panneau (source unique).
tbl = Path("app/templates/_database_table.html").read_text(encoding="utf-8")
assert 'id="db-side-peek"' not in tbl
def test_open_button_carries_its_title_as_attribute():
"""Le titre est lu depuis `dataset.openTitle` : disponible aussi dans le
calendrier et la vue Jour, où il n'y a pas de ligne `.mt-row`."""
@@ -1520,4 +1582,6 @@ def test_db_side_peek_reports_resolution_failure_in_the_panel():
assert "srcdoc" in block, "erreur rendue dans le panneau"
assert "Impossible d’ouvrir le contenu" in block
assert block.index("srcdoc") < block.index("panel.style.display = 'flex'")
# Le panneau (révélé par `classList.add('open')` depuis la réécriture
# vanilla JS de v7.49.0) doit s'afficher APRÈS l'injection du message.
assert block.index("srcdoc") < block.index("classList.add('open')")
+127
View File
@@ -0,0 +1,127 @@
"""v7.51.0 — Menu + de l'assistant : sections, parcours fichiers/répertoires,
jeton ``folder:<id>`` résolu par ContextBuilder."""
from pathlib import Path
from app.db import get_conn
from app.services.context_builder import ContextBuilder
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
def _uid() -> int:
with get_conn() as conn:
row = conn.execute("SELECT id FROM users LIMIT 1").fetchone()
return row["id"] if row else 1
def _folder_with_children(n_children: int = 2, body: str = "Contenu enfant.") -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
"VALUES ('ws', 'Dossier Specs', '', 'blocks', 'Workspace')"
)
fid = cur.lastrowid
for i in range(n_children):
conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, "
"parent_section, parent_id) VALUES ('ws', ?, ?, 'markdown', 'Private', ?)",
(f"Spec {i}", body * 10 if i else body, fid),
)
conn.commit()
return fid
def test_folder_mention_resolves_children(client):
"""folder:<id> = titre du dossier + contenu de ses documents directs."""
fid = _folder_with_children()
ctx = ContextBuilder(_uid()).build(mentions=[f"folder:{fid}"])
assert "Dossier Specs" in ctx
assert "Contenu enfant." in ctx
def test_folder_mention_truncates_on_budget(client):
"""Le contenu d'un dossier est borné (~12k) : un gros dossier n'explose pas le prompt."""
fid = _folder_with_children(n_children=12, body="X" * 800)
ctx = ContextBuilder(_uid()).build(mentions=[f"folder:{fid}"])
assert "tronqué" in ctx
assert len(ctx) < 20000
def test_folder_mention_unknown_id(client):
ctx = ContextBuilder(_uid()).build(mentions=["folder:999999"])
assert "introuvable" in ctx
def test_browse_contract_nav_menu(client):
"""Contrat que le parcours du menu + consomme : dossier = icon 'folder',
un niveau par appel via parent_id."""
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('BrowseWS', 1)")
ws_id = cur.lastrowid
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id) VALUES ('BrowseWS', ?, 'Dossier A', '', 'blocks', "
"'Workspace', NULL)",
(ws_id,),
)
folder_id = cur.lastrowid
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id) VALUES ('BrowseWS', ?, 'Note enfant', '', 'markdown', "
"'Private', ?)",
(ws_id, folder_id),
)
conn.commit()
root = client.get(f"/api/nav/menu?workspace_id={ws_id}").json()["items"]
names = {i["name"]: i for i in root}
assert names["Dossier A"]["icon"] == "folder"
kids = client.get(f"/api/nav/menu?workspace_id={ws_id}&parent_id={folder_id}").json()["items"]
kid = {i["name"]: i for i in kids}["Note enfant"]
assert kid["icon"] != "folder"
assert kid["url"] == f"/pages/{kid['id']}"
def test_agent_panel_renders_plus_menu(client):
"""La page rend bien le menu + (markup + bouton)."""
resp = client.get("/accounts")
assert resp.status_code == 200
html = resp.text
assert "fd-ap-plus-menu" in html
assert "fd-ap-add" in html
assert "Menu d'ajout au contexte" in html
def test_plus_menu_sections_and_disabled_states():
"""Les 6 sections du menu + existent ; seules les sections déjà livrées sont
actives (le menu ne promet rien que le code ne fait)."""
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
assert f"key:{key}" in root, key
assert "action:'files'" in root and "action:'skills'" in root
# v7.58.0 : menu complet — plus aucune section « bientôt » (plugins vivant)
assert root.count("disabled:true") == 0
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
assert "action:'search'" in files and "action:'browse'" in files
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
+72
View File
@@ -0,0 +1,72 @@
"""v7.53.0 — Menu + : Design System – Canevas (créer / insérer / enregistrer)."""
import json
from pathlib import Path
from app.db import get_conn
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
def test_canvases_list_includes_design_system(client):
tpl = {t["key"]: t for t in client.get("/board/api/page-templates").json()["templates"] if t.get("builtin")}
assert "design_system" in tpl
assert tpl["design_system"]["name"] == "Design System"
assert tpl["design_system"]["icon"] == "🎨"
def test_blocks_route_builtin(client):
r = client.get("/board/api/page-templates/0/blocks", params={"key": "design_system"})
assert r.status_code == 200, r.text
blocks = r.json()["blocks"]
assert isinstance(blocks, list) and blocks
assert blocks[0]["type"] == "heading_1" and blocks[0]["content"] == "Design System"
# les builtins sont livrés sans id : le front appelle ensureBlockIds()
assert "id" not in blocks[0]
assert client.get("/board/api/page-templates/0/blocks", params={"key": "nope"}).status_code == 404
empty = client.get("/board/api/page-templates/0/blocks", params={"key": "empty"})
assert empty.status_code == 200 and empty.json()["blocks"]
def test_blocks_route_user_template(client):
blocks = [{"type": "heading_1", "content": "Mon canevas"},
{"type": "paragraph", "content": "Contenu."}]
created = client.post("/board/api/page-templates", json={"name": "Mon canevas", "blocks": blocks})
assert created.status_code == 200, created.text
tid = created.json()["id"]
got = client.get(f"/board/api/page-templates/{tid}/blocks")
assert got.status_code == 200
assert [b["content"] for b in got.json()["blocks"]] == ["Mon canevas", "Contenu."]
assert client.get("/board/api/page-templates/999999/blocks").status_code == 404
def test_use_design_system_creates_page(client):
r = client.post("/board/api/page-templates/0/use", json={"key": "design_system"})
assert r.status_code == 200, r.text
page_id = r.json()["id"]
with get_conn() as conn:
row = conn.execute(
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
).fetchone()
assert row["content_format"] == "blocks"
blocks = json.loads(row["content"])
texts = [b.get("content", "") for b in blocks]
assert "Design System" in texts
# les ids sont assignés côté serveur (_ensure_block_ids)
assert all(b.get("id") for b in blocks)
def test_canvases_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'canvases'" in root
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
assert action in src, action
# piège /use : la clé builtin part DANS LE BODY, pas en query string
assert "body: JSON.stringify(c.builtin ? {key: c.key} : {})" in src
assert "ensureBlockIds" in src
+137
View File
@@ -0,0 +1,137 @@
"""v7.54.0 — Mémoire de l'agent : toggle par conversation, injection au contexte."""
import asyncio
from pathlib import Path
from app.db import get_conn
from app.services import agent_memory
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
ENGINE = ROOT / "app" / "services" / "agent_engine.py"
def _conv(client) -> tuple[int, dict]:
r = client.post("/api/agent/conversations", json={"title": "mémoire"})
assert r.status_code == 200, r.text
return r.json()["id"], r.json()
def test_migration_created_column_and_table(client):
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_conversations)")}
assert "memory_enabled" in cols
tbl = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='agent_memory'"
).fetchone()
assert tbl is not None
def test_create_conversation_defaults_to_memory_on(client):
_, created = _conv(client)
assert created["memory_enabled"] == 1 # settings.agent_memory_default
def test_patch_conversation_toggles_memory_persisted(client):
cid, _ = _conv(client)
assert client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0}).status_code == 200
rows = {c["id"]: c for c in client.get("/api/agent/conversations").json()["conversations"]}
assert rows[cid]["memory_enabled"] == 0
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 1})
rows = {c["id"]: c for c in client.get("/api/agent/conversations").json()["conversations"]}
assert rows[cid]["memory_enabled"] == 1
def test_context_for_on_off_and_persistence(client):
cid, _ = _conv(client)
agent_memory.remember(cid, "Où en est le design system ?", "Les tokens sont dans design-tokens.css.")
ctx = agent_memory.context_for(cid)
assert "Mémoire de la conversation" in ctx
assert "design-tokens.css" in ctx
# OFF → plus rien n'est lu ni écrit
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0})
assert agent_memory.context_for(cid) == ""
agent_memory.remember(cid, "Effacé ?", "Ne doit pas apparaître.")
assert agent_memory.context_for(cid) == ""
# ON à nouveau → la mémoire écrite avant le OFF est toujours là (persistée)
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 1})
assert "design-tokens.css" in agent_memory.context_for(cid)
assert "Ne doit pas apparaître" not in agent_memory.context_for(cid)
def test_memory_budget_and_note_lines_bounded(client):
cid, _ = _conv(client)
for i in range(30):
agent_memory.remember(cid, f"question {i} " + "x" * 300, "réponse " + "y" * 900)
ctx = agent_memory.context_for(cid)
header = "## Mémoire de la conversation (échanges précédents)"
assert ctx.startswith(header)
assert len(ctx) <= agent_memory.INJECT_BUDGET + len(header) + 60
with get_conn() as conn:
content = conn.execute(
"SELECT content FROM agent_memory WHERE conversation_id=?", (cid,)
).fetchone()["content"]
assert content.count("\n") + 1 <= agent_memory.NOTE_LINES
async def _drain(engine, cid, objective):
"""Consomme le générateur SSE de engine.run()."""
return [ev async for ev in engine.run(cid, objective)]
class _Resp:
def __init__(self, text=""):
self.text = text
self.tool_calls = []
self.usage = {}
class _RecorderLLM:
def __init__(self):
self.last_user = ""
async def complete(self, messages, *, model=None, tools=None, stream=False):
self.last_user = next(m["content"] for m in messages if m["role"] == "user")
return _Resp("C'est noté.")
def test_engine_run_injects_memory_when_on(client):
"""Le run réel pousse la mémoire dans le prompt utilisateur (toggle ON)."""
from app.services.agent_engine import AgentEngine
cid, _ = _conv(client)
agent_memory.remember(cid, "Priorité du sprint", "Livrer le menu + en priorité.")
fake = _RecorderLLM()
engine = AgentEngine(1, llm=fake)
asyncio.run(_drain(engine, cid, "Quelle est la priorité ?"))
assert "Mémoire de la conversation" in fake.last_user
assert "menu +" in fake.last_user
def test_engine_run_omits_memory_when_off(client):
from app.services.agent_engine import AgentEngine
cid, _ = _conv(client)
agent_memory.remember(cid, "Secret", "ne pas injecter")
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0})
fake = _RecorderLLM()
engine = AgentEngine(1, llm=fake)
asyncio.run(_drain(engine, cid, "Bonjour"))
assert "Mémoire de la conversation" not in fake.last_user
assert "ne pas injecter" not in fake.last_user
def test_memory_menu_toggle_wired():
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'memory'" in root
assert "bientôt (phase 4)" not in root
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
assert "toggleMemory()" in src
assert "memory_enabled: next" in src
engine = ENGINE.read_text(encoding="utf-8")
assert "agent_memory.context_for(" in engine
assert "agent_memory.remember(" in engine
+172
View File
@@ -0,0 +1,172 @@
"""v7.55.0 — Connecteurs de l'agent : catalogue, CRUD, SSRF, statut, outil LLM."""
import asyncio
from pathlib import Path
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import connectors
from app.services.sso_provisioning import decrypt_secret
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
PUBLIC_URL = "https://example.com/api"
def _create(client, **kw):
body = {"name": "Conn perso", "url": PUBLIC_URL, "secret": "sk-live-abc", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
def test_native_connectors_always_listed(client):
rows = client.get("/api/agent/connectors").json()["connectors"]
native = {r["kind"]: r for r in rows if r["builtin"]}
assert set(native) == {"gitea", "github", "web", "google", "ms365"}
for r in native.values():
assert r["id"] is None
assert r["status"] in ("ok", "missing")
assert r["enabled"] is True
assert native["web"]["status"] == "ok"
# les connecteurs OAuth n'existent que si configurés (client_id/secret)
assert native["google"]["oauth"] is True
assert native["google"]["status"] == "missing"
def test_create_custom_connector_never_returns_secret(client):
row = _create(client)
assert row["kind"] == "custom" and row["builtin"] is False
assert row["has_secret"] is True
assert "sk-live-abc" not in str(row) # jamais en clair dans la réponse
with get_conn() as conn:
stored = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?", (row["id"],)
).fetchone()["secret_encrypted"]
assert stored and "sk-live-abc" not in stored # chiffré (Fernet)
assert decrypt_secret(stored) == "sk-live-abc"
@pytest.mark.parametrize("bad", [
"http://localhost/secret",
"http://127.0.0.1:8080/admin",
"http://169.254.169.254/latest/meta-data/",
"ftp://exemple.com/api",
"file:///etc/passwd",
])
def test_create_rejects_non_public_urls(client, bad):
r = client.post("/api/agent/connectors", json={"name": "interne", "url": bad})
assert r.status_code == 400, r.text
def test_patch_toggle_and_delete(client):
row = _create(client)
cid = row["id"]
off = client.patch(f"/api/agent/connectors/{cid}", json={"enabled": False})
assert off.status_code == 200 and off.json()["enabled"] is False
listed = {r["id"]: r for r in client.get("/api/agent/connectors").json()["connectors"]}
assert listed[cid]["enabled"] is False
assert client.delete(f"/api/agent/connectors/{cid}").status_code == 200
assert client.delete(f"/api/agent/connectors/{cid}").status_code == 404
assert client.patch(f"/api/agent/connectors/{cid}", json={"enabled": True}).status_code == 404
def test_connectors_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/connectors").status_code == 401
assert client.post("/api/agent/connectors",
json={"name": "x", "url": PUBLIC_URL}).status_code == 401
def test_probe_persists_status(client, monkeypatch):
row = _create(client)
cid = row["id"]
async def ok_get(url, headers=None):
assert url.startswith("https://example.com") # URL du connecteur
assert (headers or {}).get("Authorization") == "Bearer sk-live-abc" # clé déchiffrée
return 200, "{}"
monkeypatch.setattr(connectors, "_get", ok_get)
res = _probe(client, str(cid))
assert res["status"] == "ok", res
with get_conn() as conn:
st = conn.execute("SELECT status FROM agent_connectors WHERE id=?", (cid,)).fetchone()
assert st["status"] == "ok"
def _probe(client, target):
r = client.post("/api/agent/connectors/probe", json={"connector": target})
assert r.status_code == 200, r.text
return r.json()
def test_probe_error_is_persisted(client, monkeypatch):
row = _create(client)
cid = row["id"]
async def boom(url, headers=None):
raise ValueError("Hôte non autorisé")
monkeypatch.setattr(connectors, "_get", boom)
res = _probe(client, str(cid))
assert res["status"] == "error"
assert "Hôte non autorisé" in res["detail"]
with get_conn() as conn:
st = conn.execute("SELECT status, detail FROM agent_connectors WHERE id=?",
(cid,)).fetchone()
assert st["status"] == "error"
def test_connector_fetch_tool_registered_and_works(client, monkeypatch):
from app.services.tool_registry import ToolRegistry
reg = ToolRegistry()
schema = {t["name"]: t for t in reg.schema()}
assert "connector_fetch" in schema
assert schema["connector_fetch"]["parameters"]["required"] == ["connector"]
row = _create(client)
async def ok_get(url, headers=None):
return 200, '{"ok": true, "source": "exemple"}'
monkeypatch.setattr(connectors, "_get", ok_get)
res = asyncio.run(reg.execute("connector_fetch", {"connector": str(row["id"]),
"path": "/status"}))
assert res.status == "success"
assert "exemple" in res.data["text"]
# connecteur désactivé → refusé
client.patch(f"/api/agent/connectors/{row['id']}", json={"enabled": False})
res2 = asyncio.run(reg.execute("connector_fetch", {"connector": str(row["id"])}))
assert res2.status == "error"
assert "désactivé" in res2.message.lower()
# connecteur inconnu → erreur outil (pas d'exception qui casse le run)
res3 = asyncio.run(reg.execute("connector_fetch", {"connector": "999999"}))
assert res3.status == "error"
def test_connectors_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'connectors'" in root
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
for action in ("'connector'", "'connector-new'", "'connector-probe'",
"'connector-toggle'", "'connector-delete'"):
assert action in src, action
for fn in ("fetchConnectors()", "connectorCreate()", "connectorProbe()",
"connectorToggle()", "connectorDelete()"):
assert fn in src, fn
html = PANEL_HTML.read_text(encoding="utf-8")
assert "plusSection==='connector-new'" in html
assert 'type="password"' in html # la clé n'est pas en clair à l'écran
resp = client.get("/accounts")
assert resp.status_code == 200 and "connectorForm" in resp.text
+231
View File
@@ -0,0 +1,231 @@
"""v7.56.0 — Connecteurs OAuth Google / Microsoft 365 (PKCE, tokens, refresh)."""
import asyncio
import time
from pathlib import Path
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import oauth_connectors
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
@pytest.fixture
def configured(monkeypatch):
"""Clients OAuth fictifs (aucune clé réelle nécessaire pour les tests)."""
from app.config import settings
monkeypatch.setattr(settings, "google_client_id", "gid-test")
monkeypatch.setattr(settings, "google_client_secret", "gsec-test")
monkeypatch.setattr(settings, "ms_client_id", "mid-test")
monkeypatch.setattr(settings, "ms_client_secret", "msec-test")
monkeypatch.setattr(settings, "app_base_url", "https://flowdeck.example")
return settings
def _begin(client, kind="google"):
r = client.post(f"/api/agent/connectors/oauth/{kind}/authorize")
assert r.status_code == 200, r.text
return r.json()
def test_authorize_url_has_pkce_scope_and_state(client, configured):
data = _begin(client)
url = data["url"]
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth?")
assert "client_id=gid-test" in url
assert "code_challenge=" in url and "code_challenge_method=S256" in url
assert "drive.readonly" in url and "gmail.readonly" in url and "calendar.readonly" in url
assert "state=" in url
assert "redirect_uri=" in url and "api%2Fagent%2Fconnectors%2Foauth%2Fgoogle%2Fcallback" in url
# les cookies d'état partent avec la réponse (10 min, HttpOnly)
for key in ("fd_oauth_state_google", "fd_oauth_verifier_google", "fd_oauth_next_google"):
assert key in client.cookies
def test_authorize_without_client_config_is_400(client):
r = client.post("/api/agent/connectors/oauth/google/authorize")
assert r.status_code == 400
assert "non configuré" in r.json()["detail"]
def test_callback_saves_tokens_encrypted_and_redirects(client, configured, monkeypatch):
_begin(client)
state = client.cookies["fd_oauth_state_google"]
async def fake_post(url, form=None, **kw):
assert url == "https://oauth2.googleapis.com/token"
assert form["grant_type"] == "authorization_code"
assert form["code"] == "abc123"
assert form["code_verifier"] == client.cookies["fd_oauth_verifier_google"]
return {"access_token": "at-1", "refresh_token": "rt-1",
"expires_in": 3600, "scope": "openid email"}
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
resp = client.get(
"/api/agent/connectors/oauth/google/callback",
params={"code": "abc123", "state": state},
follow_redirects=False,
)
assert resp.status_code == 302
assert "oauth=connected" in resp.headers["location"]
with get_conn() as conn:
row = conn.execute("SELECT tokens_enc FROM connector_tokens").fetchone()
assert row and "at-1" not in row["tokens_enc"] # chiffré
assert oauth_connectors.tokens("google", 1)["access_token"] == "at-1"
status = client.get("/api/agent/connectors/oauth/google/status").json()
assert status["connected"] is True and status["configured"] is True
def test_callback_rejects_state_mismatch(client, configured, monkeypatch):
called = []
async def fake_post(url, form=None, **kw):
called.append(form)
return {"access_token": "at"}
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
resp = client.get(
"/api/agent/connectors/oauth/google/callback",
params={"code": "abc", "state": "forged"}, # != cookie
follow_redirects=False,
)
assert resp.status_code == 302 and "oauth_error=state" in resp.headers["location"]
assert called == []
assert not oauth_connectors.is_connected("google", 1)
def test_callback_without_session_redirects(client, configured, monkeypatch):
_begin(client)
state = client.cookies["fd_oauth_state_google"]
async def fake_post(url, form=None, **kw):
return {"access_token": "at"}
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
# on retire la SEULE session (anon_csrf effacerait aussi le cookie d'état OAuth)
client.cookies.delete("flowdeck_session")
client.auth = None
resp = client.get(
"/api/agent/connectors/oauth/google/callback",
params={"code": "abc", "state": state},
follow_redirects=False,
)
assert resp.status_code == 302 and "oauth_error=session" in resp.headers["location"]
def test_refresh_token_flow(client, configured, monkeypatch):
oauth_connectors.save("google", 1, {
"access_token": "old", "refresh_token": "rt-keep",
"expires_at": int(time.time()) - 10, "scope": "openid",
})
calls = []
async def fake_post(url, form=None, **kw):
calls.append(form)
return {"access_token": "new", "expires_in": 3600} # pas de refresh_token
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
access = asyncio.run(oauth_connectors.access_token("google", 1))
assert access == "new"
assert calls[0]["grant_type"] == "refresh_token"
assert calls[0]["refresh_token"] == "rt-keep"
stored = oauth_connectors.tokens("google", 1)
assert stored["refresh_token"] == "rt-keep" # conservé si absent de la réponse
assert stored["expires_at"] > time.time()
def test_api_get_bearer_and_refuses_absolute_url(client, configured, monkeypatch):
oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "",
"expires_at": int(time.time()) + 3600,
"scope": "openid"})
seen = []
async def fake_get(url, headers=None):
seen.append((url, headers))
return 200, '{"emails": 3}'
monkeypatch.setattr(oauth_connectors, "_api_get", fake_get)
res = asyncio.run(oauth_connectors.api_get("google", 1, "/gmail/v1/users/me/profile"))
assert res["status"] == "ok"
assert seen[0][0].startswith("https://www.googleapis.com/gmail/v1/")
assert seen[0][1]["Authorization"] == "Bearer at"
# une URL absolue dans `path` ne doit pas dévier (hôte fixe + validation)
res2 = asyncio.run(oauth_connectors.api_get("google", 1, "https://evil.example/x"))
assert res2["status"] == "error"
assert len(seen) == 1 # aucun appel réseau
def test_api_get_requires_connection(client, configured):
res = asyncio.run(oauth_connectors.api_get("google", 1, "/me"))
assert res["status"] == "error" and "non connecté" in res["text"]
def test_disconnect_clears_tokens(client, configured):
oauth_connectors.save("ms365", 1, {"access_token": "at", "expires_at": 1, "scope": ""})
assert oauth_connectors.is_connected("ms365", 1)
r = client.post("/api/agent/connectors/oauth/ms365/disconnect")
assert r.status_code == 200 and r.json()["status"] == "disconnected"
assert not oauth_connectors.is_connected("ms365", 1)
def test_catalogue_marks_oauth_connectors(client, configured):
rows = client.get("/api/agent/connectors").json()["connectors"]
by_kind = {r["kind"]: r for r in rows if r["builtin"]}
assert "google" in by_kind and "ms365" in by_kind
assert by_kind["google"]["oauth"] is True
assert by_kind["google"]["status"] == "missing" # configuré mais pas connecté
assert "non connecté" in by_kind["google"]["detail"]
oauth_connectors.save("google", 1, {"access_token": "at", "expires_at": 9_999_999_999,
"scope": "openid email drive"})
rows = client.get("/api/agent/connectors").json()["connectors"]
g = {r["kind"]: r for r in rows if r["builtin"]}["google"]
assert g["status"] == "ok" and "connecté" in g["detail"]
def test_tool_dispatches_to_oauth_api(client, configured, monkeypatch):
from app.services.tool_registry import ToolRegistry
oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "",
"expires_at": int(time.time()) + 3600,
"scope": "openid"})
async def fake_get(url, headers=None):
assert url.startswith("https://www.googleapis.com/")
assert headers["Authorization"] == "Bearer at"
return 200, '{"name": "Bruno"}'
monkeypatch.setattr(oauth_connectors, "_api_get", fake_get)
res = asyncio.run(ToolRegistry().execute(
"connector_fetch", {"connector": "google", "path": "/oauth2/v3/userinfo"},
user_id=1))
assert res.status == "success"
assert "Bruno" in res.data["text"]
def test_oauth_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/connectors/oauth/google/status").status_code == 401
assert client.post("/api/agent/connectors/oauth/google/authorize").status_code == 401
assert client.post("/api/agent/connectors/oauth/google/disconnect").status_code == 401
# kind inconnu → 404 même authentifié
assert client.post("/api/agent/connectors/oauth/dropbox/authorize").status_code == 404
def test_oauth_menu_wired():
src = JS_PATH.read_text(encoding="utf-8")
for token in ("'connector-connect'", "'connector-disconnect'",
"connectorConnect()", "connectorDisconnect()",
"qs.get('oauth_error')", "Connecteur connecté."):
assert token in src, token
assert "c.oauth" in src
# le fournisseur revient avec `oauth=connected` (côté route)
router = (ROOT / "app" / "routers" / "agent.py").read_text(encoding="utf-8")
assert "oauth=connected" in router
+209
View File
@@ -0,0 +1,209 @@
"""v7.57.0 — Discord / Telegram (presets) + serveurs MCP (outils dynamiques)."""
import asyncio
from pathlib import Path
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import connectors, mcp_client
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
def _create(client, **kw):
body = {"name": "Conn", "url": "https://example.com/v1", "secret": "sk-1", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
# ── Presets Discord / Telegram ──────────────────────────────────────────────
def test_discord_preset_uses_bot_auth(client):
row = _create(client, name="Discord", url="https://discord.com/api/v10",
auth="bot", kind="discord")
assert row["kind"] == "discord" and row["auth"] == "bot"
assert connectors._headers(row["id"])["Authorization"].startswith("Bot ")
def test_telegram_secret_lives_in_url_not_in_db(client, monkeypatch):
row = _create(client, name="Telegram", kind="telegram", auth="none",
url="https://api.telegram.org/bot{secret}", secret="123456:abc")
with get_conn() as conn:
stored = conn.execute("SELECT url FROM agent_connectors WHERE id=?",
(row["id"],)).fetchone()["url"]
assert "{secret}" in stored and "123456" not in stored # jamais en clair
seen = []
async def fake_get(url, headers=None):
seen.append((url, headers))
return 200, '{"ok": true}'
monkeypatch.setattr(connectors, "_get", fake_get)
asyncio.run(connectors.connector_fetch(str(row["id"]), path="getMe"))
assert seen[0][0] == "https://api.telegram.org/bot123456:abc/getMe"
assert "Authorization" not in seen[0][1] # auth=none
def test_secret_placeholder_requires_key_and_valid_kinds(client):
no_key = client.post("/api/agent/connectors",
json={"name": "TG", "url": "https://api.telegram.org/bot{secret}"})
assert no_key.status_code == 400 and "secret" in no_key.json()["detail"]
bad_kind = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "kind": "slack"})
assert bad_kind.status_code == 400 and "kind" in bad_kind.json()["detail"]
bad_auth = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "auth": "digest"})
assert bad_auth.status_code == 400 and "auth" in bad_auth.json()["detail"]
# ── MCP : handshake, cache, outils dynamiques ───────────────────────────────
def _mcp_server(client) -> int:
return _create(client, name="Demo Server", kind="mcp", url="https://example.com/mcp")["id"]
def _fake_rpc_factory(calls):
async def fake_rpc(url, payload, headers=None):
assert url.startswith("https://example.com/mcp") # SSRF: hôte public fixe
calls.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2024-11-05",
"serverInfo": {"name": "demo"}}}
if method == "tools/list":
return {"result": {"tools": [
{"name": "search_docs", "description": "Cherche dans les notes",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}},
"required": ["q"]}},
{"name": "Echo", "description": "Répète"},
]}}
if method == "tools/call":
return {"result": {"content": [{"type": "text", "text": "résultat utile"}]}}
return {}
return fake_rpc
def test_mcp_probe_handshakes_and_caches_tools(client, monkeypatch):
sid = _mcp_server(client)
calls = []
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory(calls))
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "ok", res
assert "2 outil(s)" in res["detail"]
assert calls == ["initialize", "notifications/initialized", "tools/list"]
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert "mcp_demo_server_search_docs" in tools # nom LLM calculé
rows = client.get("/api/agent/connectors").json()["connectors"]
me = {r["id"]: r for r in rows if r["id"] == sid}[sid]
assert me["kind"] == "mcp" and me["tools_count"] == 2
def test_dynamic_tools_reach_the_llm_schema_and_execute(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
reg = ToolRegistry()
schema = {t["name"]: t for t in reg.schema()}
assert "mcp_demo_server_search_docs" in schema
assert schema["mcp_demo_server_search_docs"]["parameters"]["required"] == ["q"]
assert "mcp_demo_server_echo" in schema
res = asyncio.run(reg.execute("mcp_demo_server_search_docs", {"q": "notes"}))
assert res.status == "success"
assert "résultat utile" in res.data["text"]
def test_disabled_mcp_server_hides_its_tools(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
assert "mcp_demo_server_echo" in {t["name"] for t in ToolRegistry().schema()}
client.patch(f"/api/agent/connectors/{sid}", json={"enabled": False})
assert "mcp_demo_server_echo" not in {t["name"] for t in ToolRegistry().schema()}
# hors du registre → refus explicite plutôt qu'un appel réseau raté
res = asyncio.run(ToolRegistry().execute("mcp_demo_server_echo", {}))
assert res.status == "error" and "inconnu" in res.message.lower()
def test_mcp_rpc_errors_become_probe_errors(client, monkeypatch):
sid = _mcp_server(client)
async def bad_rpc(url, payload, headers=None):
if payload.get("method") == "initialize":
raise ValueError("MCP -32000 : handshake refusé")
return {}
monkeypatch.setattr(mcp_client, "_rpc", bad_rpc)
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "error" and "handshake" in res["detail"]
# les outils restent absents : pas de cache partiel
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert tools == "[]"
def test_parse_body_handles_sse_and_rpc_errors():
sse = mcp_client.parse_body(
"text/event-stream; charset=utf-8",
": keep-alive\nevent: message\ndata: {\"result\": {\"ok\": 1}}\n\n", 200)
assert sse == {"result": {"ok": 1}}
assert mcp_client.parse_body("application/json", '{"result": {}}', 200) == {"result": {}}
with pytest.raises(ValueError, match="illisible"):
mcp_client.parse_body("text/html", "<html>gateway</html>", 502)
with pytest.raises(ValueError, match="-32601"):
mcp_client.parse_body("application/json",
'{"error": {"code": -32601, "message": "Method not found"}}', 200)
def test_tool_name_slug():
assert mcp_client.tool_name("Demo Server", "search-docs") == "mcp_demo_server_search_docs"
assert mcp_client.tool_name("!!", "Echo") == "mcp_echo"
# ── Teams + câblage ─────────────────────────────────────────────────────────
def test_ms365_scopes_include_teams_messages():
from app.services.oauth_connectors import PROVIDERS
assert "ChannelMessage.Read.All" in PROVIDERS["ms365"]["scopes"]
assert "Files.Read" in PROVIDERS["ms365"]["scopes"]
def test_connectors_menu_wired_for_presets(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("connectorPreset()", "kind: f.kind || 'custom'",
"auth: f.auth || 'bearer'",
"https://discord.com/api/v10",
"https://api.telegram.org/bot{secret}", "presets[f.kind]"):
assert token in src, token
html = PANEL_HTML.read_text(encoding="utf-8")
assert "fd-plus-cn-kind" in html
for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
'connectorForm.kind'):
assert token in html, token
# v7.58.0 : plus aucune section « bientôt » (plugins rendu vivant)
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert root.count("disabled:true") == 0
resp = client.get("/accounts")
assert resp.status_code == 200 and "connectorPreset" in resp.text
def test_mcp_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/connectors").status_code == 401
assert client.post("/api/agent/connectors",
json={"name": "x", "url": "https://example.com/x",
"kind": "mcp"}).status_code == 401
+142
View File
@@ -0,0 +1,142 @@
"""v7.58.0 — Add plugins : registre persistant + on/off à effet réel."""
import asyncio
from pathlib import Path
from conftest import anon_csrf
from app.services import plugins
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
SETTINGS_HTML = ROOT / "app" / "templates" / "settings.html"
AUTOMATIONS_PY = ROOT / "app" / "services" / "automations.py"
def _set(client, slug: str, enabled: bool) -> dict:
r = client.patch(f"/api/agent/plugins/{slug}", json={"enabled": enabled})
assert r.status_code == 200, r.text
return r.json()
# ── Catalogue ───────────────────────────────────────────────────────────────
def test_catalog_lists_the_three_wired_plugins(client):
data = client.get("/api/agent/plugins").json()["plugins"]
assert [p["slug"] for p in data] == ["web-tools", "web-clipper", "automations"]
assert all(p["enabled"] for p in data)
assert all(p["name"] and p["description"] for p in data)
def test_unknown_slug_is_404(client):
assert client.patch("/api/agent/plugins/nope",
json={"enabled": False}).status_code == 404
def test_toggle_persists_in_db(client):
_set(client, "web-clipper", False)
assert plugins.is_enabled("web-clipper") is False # relu depuis la base
_set(client, "web-clipper", True)
assert plugins.is_enabled("web-clipper") is True
# ── Effet réel : routes refusées ────────────────────────────────────────────
def test_automations_routes_refused_when_plugin_off(client):
# 404 hors /api = redirection vers /workspaces (handler unifié de main.py)
def get():
return client.get("/workspace/automations", follow_redirects=False)
assert get().status_code == 200
_set(client, "automations", False)
assert get().status_code == 302
assert get().headers["location"] == "/workspaces"
assert client.post("/workspace/automations", json={},
follow_redirects=False).status_code == 302
assert plugins.is_enabled("automations") is False
_set(client, "automations", True)
assert get().status_code == 200
def test_scheduler_tick_guarded_when_automations_off(client):
# le scheduler doit vérifier le plugin avant chaque tick ( effet réel, pas un
# drapeau lu nulle part )
src = AUTOMATIONS_PY.read_text(encoding="utf-8")
assert 'plugins.is_enabled("automations")' in src
_set(client, "automations", False)
assert plugins.is_enabled("automations") is False
def test_web_clipper_api_and_page_refused_when_plugin_off(client):
def page():
return client.get("/extensions", follow_redirects=False)
assert client.get("/api/v2/web-clipper/status").status_code == 200
assert page().status_code == 200
_set(client, "web-clipper", False)
assert client.get("/api/v2/web-clipper/status").status_code == 404
assert client.post("/api/v2/web-clipper/clip", json={}).status_code == 404
assert page().status_code == 302 and page().headers["location"] == "/workspaces"
_set(client, "web-clipper", True)
assert client.get("/api/v2/web-clipper/status").status_code == 200
assert page().status_code == 200
# ── Effet réel : outils de l'agent ──────────────────────────────────────────
def test_web_tools_leaves_the_llm_registry_when_plugin_off(client):
names = {t["name"] for t in ToolRegistry().schema()}
assert {"web_search", "fetch_url"} <= names
_set(client, "web-tools", False)
names = {t["name"] for t in ToolRegistry().schema()}
assert not ({"web_search", "fetch_url"} & names)
res = asyncio.run(ToolRegistry().execute("fetch_url",
{"url": "https://example.com"}))
assert res.status == "error" and "inconnu" in res.message.lower()
_set(client, "web-tools", True)
names = {t["name"] for t in ToolRegistry().schema()}
assert {"web_search", "fetch_url"} <= names
# ── UI : settings rendu côté serveur + menu + ──────────────────────────────
def test_settings_hides_disabled_plugins(client):
html = client.get("/settings").text
assert "navTo('extensions')" in html and "navTo('automations')" in html # ON
_set(client, "automations", False)
_set(client, "web-clipper", False)
html = client.get("/settings").text
assert "navTo('automations')" not in html # nav retirée
assert "navTo('extensions')" not in html
assert "x-show=\"false &amp;&amp; activeSection==='automations'\"" in html
_set(client, "automations", True)
_set(client, "web-clipper", True)
html = client.get("/settings").text
assert "navTo('automations')" in html and "navTo('extensions')" in html
def test_menu_catalogue_is_live(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("action:'plugins'", "_pluginItems()", "pluginToggle(slug)",
"/api/agent/plugins", "Add plugins"):
assert token in src, token
# la section n'est plus une coquille « bientôt »
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "disabled:true" not in root
panel = PANEL_HTML.read_text(encoding="utf-8")
assert "plusSection!=='root'" in panel # en-tête/rétour génériques
settings = SETTINGS_HTML.read_text(encoding="utf-8")
assert settings.count("plugin_enabled(") >= 4
def test_plugins_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/plugins").status_code == 401
assert client.patch("/api/agent/plugins/automations",
json={"enabled": False}).status_code == 401
+100
View File
@@ -0,0 +1,100 @@
"""7.59.2 — corrections : bouton ✕ de l'aide, « Insert below » qui perd le
format, listes numérotées invisibles (agent + /numbered list)."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
HELP_HTML = ROOT / "app" / "templates" / "help.html"
HELP_JS = ROOT / "static" / "js" / "help.js"
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
APP_CSS = ROOT / "static" / "css" / "app.css"
SETTINGS_CSS = ROOT / "static" / "css" / "settings.css"
# ── 1. Aide : fermeture en haut à droite (mobile compris) ───────────────────
def test_help_page_has_close_button():
html = HELP_HTML.read_text(encoding="utf-8")
js = HELP_JS.read_text(encoding="utf-8")
css = SETTINGS_CSS.read_text(encoding="utf-8")
assert 'class="settings-close"' in html # bouton ✕ présent
assert '@click="close()"' in html
assert "close()" in js and "history.back()" in js # méthode réelle
assert ".settings-close{" in css # style partagé (32/44px)
assert "position:relative" in css.split(".settings-panel{")[1][:40]
# ── 2. Insert below : on insère le MARKDOWN, pas le texte du HTML rendu ─────
def test_ai_insert_below_keeps_source_markdown():
src = EDITOR_JS.read_text(encoding="utf-8")
assert "self._md=String(text||'')" in src # source conservée
assert "String(this._md||'').trim()||this._resultText()" in src # md d'abord
assert src.count("this._md='';") >= 2 # close() + _err()
# l'ancien chemin (texte extrait du DOM rendu) n'est plus que le repli
assert "_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result')" in src
# ── 3. Listes numérotées : numéros réellement affichés ──────────────────────
def test_numbered_list_renders_its_number():
src = EDITOR_JS.read_text(encoding="utf-8")
css = APP_CSS.read_text(encoding="utf-8")
assert "let numAttr=''" in src and "data-num=" in src # rangée contiguë → rang
assert ".block-numbered[data-num]::before" in css
assert 'content: attr(data-num) ". "' in css
# md2b accepte « 1. » et « 1) » (CommonMark) pour l'insertion agent
assert r"/^\d+[.)] /" in src
def test_slash_query_typed_in_block_is_synced_to_filter():
src = EDITOR_JS.read_text(encoding="utf-8")
# « /numbered list » tapé dans le bloc → répercuté dans l'entrée du menu
assert "SM._in.value=q" in src
assert "SM.flt();" in src
# ── md2b : test comportemental sur la source réelle (node) ──────────────────
def _md2b_source() -> str:
src = EDITOR_JS.read_text(encoding="utf-8")
i = src.index("md2b(md){")
j = src.index("return bl;},", i) + len("return bl;},")
snippet = src[i:j].rstrip().rstrip(",") # « md2b(md){…} »
return snippet
def test_md2b_converts_numbered_lists_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental md2b ignoré")
md = ("# Titre\n"
"1. premier\n"
"2. second\n"
"3) troisième\n"
"- puce\n"
"- [ ] à faire\n"
"> citation\n")
script = (
"function _tblIsData(){return false;}\n"
"function _tblSplitLine(){return [];}\n"
"const editor={mkB(t,c,e){const o={type:t,content:c||''};"
"if(e)Object.assign(o,e);return o;},\n"
f"{_md2b_source()}}};\n"
f"console.log(JSON.stringify(editor.md2b({json.dumps(md)})));\n"
)
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
blocks = json.loads(out.stdout)
assert [b["type"] for b in blocks] == [
"heading_1",
"numbered_list", "numbered_list", "numbered_list", # 1. 2. 3)
"bulleted_list", "to_do", "quote",
]
assert [b["content"] for b in blocks[1:4]] == ["premier", "second", "troisième"]