Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f271ac9b7a | ||
|
|
f006880394 | ||
|
|
81746d9440 | ||
|
|
f53205bc45 | ||
|
|
3706689eca |
+119
@@ -1,5 +1,124 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v7.51.0 (2026-10-06) — Menu + de l'assistant : hub de contexte (phase 1/8)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Menu à sections sur le bouton +** (`agent_panel.html` + `agent_panel_2.js`) :
|
||||||
|
le + ouvre *Ajouter des fichiers ou répertoires*, *Compétences-skills*,
|
||||||
|
*Connecteurs*, *Design System – Canevas*, *Add plugins*, *Mémoire (on/off)*.
|
||||||
|
Sections pas encore livrées affichées « bientôt (phase N) » et **désactivées**
|
||||||
|
(décision D3 : le menu ne promet rien que le code ne fait). Navigation clavier
|
||||||
|
↑/↓/Entrée/Échap, focus visible, Échap revient d'un niveau.
|
||||||
|
- **Parcours fichiers & répertoires** — « Parcourir… » affiche un niveau à la fois
|
||||||
|
via `/api/nav/menu` (contrat : dossier = `icon === 'folder'`) avec fil d'Ariane
|
||||||
|
cliquable ; un document s'épingle (`document:<id>`), un dossier s'épingle depuis
|
||||||
|
la ligne « 📌 Épingler le dossier » (`folder:<id>`).
|
||||||
|
- **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` : titre du
|
||||||
|
dossier + documents directs, budget ~12k caractères (marqueur « contenu du
|
||||||
|
dossier tronqué »). Enfants directs seulement — récursion à ajouter si les
|
||||||
|
arbres profonds deviennent réels.
|
||||||
|
- **« Rechercher… »** conserve l'ancien sélecteur de mentions (recherche par nom,
|
||||||
|
`@` inline) à l'identique : zéro régression du chemin existant.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `tests/test_v751_plus_menu.py` — **7 tests** : résolution du dossier, budget
|
||||||
|
borné, id inconnu, contrat `/api/nav/menu`, rendu du menu dans la page,
|
||||||
|
6 sections dont 5 désactivées, jeton `folder:` côté front et côté backend.
|
||||||
|
- **Suite complète : 1273 passed / 0 failed** (`-n auto`), `ruff check app tests`
|
||||||
|
propre, `eslint static/js` **0 erreur**.
|
||||||
|
|
||||||
|
|
||||||
|
## v7.50.2 (2026-10-06) — Peek de Library : nu comme local-workspace (plus de bande ni double scrollbar)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Le document ouvert par le bouton « Open » de Library n'occupait pas le
|
||||||
|
panneau** — `.peek-body` gardait un `padding:16px 20px` + `overflow-y:auto`
|
||||||
|
(héritage de l'ancien peek à rendu HTML) : l'iframe était amochée
|
||||||
|
(519×816 mesurés contre 559×848 en local-workspace) avec une bande grise
|
||||||
|
autour du document et une double scrollbar. Corps nu (`padding:0`,
|
||||||
|
`overflow:hidden`) : l'éditeur remplit le panneau, exactement comme le peek
|
||||||
|
« parfait » de /local-workspace (mesuré Playwright, cookie de session forgé).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `test_library_peek_body_has_no_inset_padding`. Suite 1266 passed / 0 failed
|
||||||
|
(-n auto) + ruff 0.
|
||||||
|
|
||||||
|
## v7.50.1 (2026-10-06) — Peek des bases : le contenu se rend enfin + tableau pleine largeur
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Une base ouverte en side peek restait à « Loading database… »** —
|
||||||
|
`page_editor_embed.html` (le peek de `/pages/{id}?embed=1`) n'incluait pas
|
||||||
|
`database_table.js` : `window.FlowDeckDB` était undefined dans l'iframe et
|
||||||
|
le bloc `embed/collection` ne s'hydrat jamais. Inclus désormais (même
|
||||||
|
traitement que `page_editor.html`).
|
||||||
|
- **L'icône « Open » de fin de ligne d'un tableau de base ne faisait rien
|
||||||
|
hors page dédiée** — le panneau `#db-side-peek` n'existait que dans le
|
||||||
|
markup de `_database_table.html` ; sans lui, `openPageInSidePeek` était un
|
||||||
|
no-op. Le panneau est créé à la demande par `database_table.js`
|
||||||
|
(`ensureDbPeek`, comme `mt-peek` de My Tasks) : markup unique, le partial
|
||||||
|
ne contient plus le panneau statique.
|
||||||
|
- **Barre de scroll sous le tableau des bases** — `.database-table-container`
|
||||||
|
plafonnait à 1100 px (règle héritée de Library) : le tableau ne prenait
|
||||||
|
pas la largeur du document. Plafond retiré ; `max-width: 100%` aussi pour
|
||||||
|
les conteneurs `.db-first` (base embarquée en premier bloc) et le titre
|
||||||
|
des pages de base — les colonnes dictent seules le scroll horizontal.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `test_embed_editor_can_render_inline_databases` (include peek + panneau
|
||||||
|
créé à la demande, source unique de markup). Suite 1265 passed / 0 failed
|
||||||
|
(-n auto) + ruff 0.
|
||||||
|
|
||||||
|
## v7.50.0 (2026-10-06) — Side peek : le document seul (mode édition), sans sidebar ni barre
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Tous les boutons « Open » ouvraient l'habillage pleine page dans le panneau
|
||||||
|
latéral** — `library.js` (OPEN de hover), `local_workspace.js` (↢ Open +
|
||||||
|
« Open in side peek » du menu contextuel) et `my_tasks.js` (peek des tâches)
|
||||||
|
chargeaient `/pages/{id}` SANS `?embed=1` : le peek affichait sidebar +
|
||||||
|
barre de navigation + éditeur (régression v7.49.0). Tous repassent en
|
||||||
|
`/pages/{id}?embed=1` = le document en mode édition SEUL ; le cadre du
|
||||||
|
panneau (garde/close/resize partagés `fdWirePeekResize`) reste identique.
|
||||||
|
- **Le bouton « Open » du calendrier My Tasks ne faisait rien** — cause
|
||||||
|
racine : le handler de clic testait `e.target.dataset.openRow`, mais les
|
||||||
|
boutons `.mt-cal-open` du calendrier (et du Kanban) ne contiennent que leur
|
||||||
|
`<svg>` : cliquer l'icône donnait `e.target` = `<svg>` sans dataset. Le
|
||||||
|
handler résout désormais `t.closest('[data-open-row]')` — un seul chemin
|
||||||
|
pour tableau, calendrier (5 vues) et Kanban.
|
||||||
|
- **Menu contextuel global « Open in side peek »** — le clic ne faisait
|
||||||
|
qu'afficher « Side peek not available in browser » : il route maintenant
|
||||||
|
vers le peek de la page courante (`_libData.openPeekById` en Library,
|
||||||
|
`_wsData.openSidePeek` en workspace local), repli nouvel onglet ailleurs.
|
||||||
|
- **Peek d'une page de base (`content_format='collection'`)** — `?embed=1`
|
||||||
|
forçait le template éditeur : une base ouverte dans le peek montrait un
|
||||||
|
éditeur de blocs vide au lieu du tableau. Le template collection est gardé
|
||||||
|
en mode embed (body.embed-mode retire sidebar + barre, le tableau reste).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Bouton « Open » sur les tuiles du Kanban My Tasks** — le même que les
|
||||||
|
lignes du tableau (`openButton(t)`, `.mt-hover-only` révélé au survol de la
|
||||||
|
carte, overlay absolu pour ne pas déformer la tuile) ; ouvre le contenu de
|
||||||
|
la tâche dans le panneau latéral.
|
||||||
|
- **Bouton « pleine page » des peeks de bases** — navigue vers
|
||||||
|
`/pages/{pageId}` (pleine page, sidebar + barre conservées) au lieu de
|
||||||
|
l'URL `?embed=1` de l'iframe ; `dataset.page` porte l'id résolu.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `test_my_tasks_workspace_coherence.py` : les 4 peek JS (`library`,
|
||||||
|
`my_tasks`, `database_table`, `local_workspace`) doivent porter
|
||||||
|
`?embed=1` (gate de cohérence inversé — l'ancien test imposait l'URL nue) ;
|
||||||
|
nouveaux : résolution `closest('[data-open-row]')`, bouton Open sur tuiles
|
||||||
|
Kanban, collection embed = tableau. Suite 1264 passed / 0 failed
|
||||||
|
(-n auto) + ruff 0.
|
||||||
|
|
||||||
## v7.49.2 (2026-10-05) — Pages de base : titre au-dessus, tableau à gauche
|
## v7.49.2 (2026-10-05) — Pages de base : titre au-dessus, tableau à gauche
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+138
@@ -284,6 +284,144 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phase 1 ✅ 2026-10-06 · phases 2-8 planifiées)
|
||||||
|
|
||||||
|
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
|
||||||
|
> un menu à sections, tel que demandé :
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> + ├─ Ajouter des fichiers ou répertoires … → picker actuel + parcours de l'arborescence
|
||||||
|
> ├─ Compétences-skills … … … … … … … … → Deep research, Skill-creator, … + Gérer + Parcourir
|
||||||
|
> ├─ Connecteurs … … … … … … … … … … … → Parcourir + Ajouter un connecteur personnalisé
|
||||||
|
> ├─ Design System – Canevas … … … … … → galerie de canevas (templates de page)
|
||||||
|
> ├─ Add plugins … … … … … … … … … … … → catalogue de modules on/off
|
||||||
|
> └─ Mémoire (on/off) … … … … … … … … → mémoire persistante de l'agent
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **Doc de design** : [`docs/V74_Agent_Plus_Menu.md`](docs/V74_Agent_Plus_Menu.md)
|
||||||
|
> — maquette, découpage front/back, modèle de données, endpoints, décisions, pièges.
|
||||||
|
|
||||||
|
**État des lieux lu dans le code le 2026-10-06** (constat **avant** la phase 1) :
|
||||||
|
|
||||||
|
- Le **+** = `toggleAddPicker()` (`static/js/agent_panel_2.js:1074`) → menu **plat** de
|
||||||
|
mentions (`GET /api/agent/mentions`, `app/routers/agent.py:596`) : documents,
|
||||||
|
collections, pages. **Pas de dossiers, pas de sous-menu, pas de section.**
|
||||||
|
- **Compétences** : 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) +
|
||||||
|
table `agent_skills` (CRUD `/api/agent/skills`) + galerie de 17 presets — mais
|
||||||
|
**aucune UI « Gérer » ni « Parcourir »** : tout passe par la palette `/`.
|
||||||
|
- **Canevas** : `GET /api/page-templates` (built-ins `app/services/block_templates.py` +
|
||||||
|
personnels) et `POST /api/page-templates/{id}/use` existent déjà, **jamais appelés
|
||||||
|
depuis le panneau agent**.
|
||||||
|
- **Connecteurs / Plugins / Mémoire** : **inexistants** — 0 hit `connector|plugin|memory`
|
||||||
|
dans `app/` hors « in-memory ».
|
||||||
|
|
||||||
|
**Ordre** : phases 1 → 8, chacune indépendante et livrable (version + tests verts +
|
||||||
|
CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
||||||
|
|
||||||
|
### Phase 1 — v7.51.0 — Menu hub + fichiers/répertoires · effort S–M ✅ (livrée 2026-10-06)
|
||||||
|
|
||||||
|
- [x] **Structure du menu** — sections + sous-menus, navigation clavier (↑/↓/Entrée/Échap),
|
||||||
|
styles réutilisés depuis `.fd-ap-mention-menu` ; les sections pas encore livrées
|
||||||
|
s'affichent « bientôt » et désactivées (le menu ne promet rien que le code ne fait)
|
||||||
|
→ `FD_PLUS_MENU` / `FD_PLUS_FILES` (`static/js/agent_panel_2.js`), markup
|
||||||
|
`.fd-ap-plus-menu` (`agent_panel.html`), Échap remonte d'un niveau, la frappe
|
||||||
|
referme le menu
|
||||||
|
- [x] **Fichiers & répertoires** — picker actuel (recherche) **+ mode « Parcourir… »** :
|
||||||
|
arborescence **un niveau par appel via `GET /api/nav/menu?parent_id=`**
|
||||||
|
(contrat : dossier = `icon === 'folder'` — plus simple que le tree récursif,
|
||||||
|
déjà testé), fil d'Ariane cliquable ; un répertoire s'épingle comme un fichier
|
||||||
|
(ligne « 📌 Épingler le dossier »)
|
||||||
|
- [x] **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` →
|
||||||
|
`_single_folder()` : titre du dossier + documents directs, budget ~12k caractères
|
||||||
|
(marqueur « tronqué ») ; enfants directs seulement (récursion si besoin réel)
|
||||||
|
- [x] **Tests** — `tests/test_v751_plus_menu.py` : **7 tests** (résolution, budget,
|
||||||
|
id inconnu, contrat `/api/nav/menu`, rendu du menu, 6 sections / 5 désactivées,
|
||||||
|
jeton `folder:` front+back) ; **suite complète 1273 verts**, `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
|
||||||
|
|
||||||
|
- [ ] **Sous-menu Compétences** — builtin + installés (badge « installé »), le clic
|
||||||
|
insère le template dans le composer (comportement `/` actuel conservé)
|
||||||
|
- [ ] **Libellés affichés** — alias visibles « Deep research » (`/research`) et
|
||||||
|
« Skill-creator » (`/create-new-skill`), sans casser les slugs existants
|
||||||
|
- [ ] **Modale « Gérer les compétences »** — CRUD sur `POST/PATCH/DELETE /api/agent/skills`
|
||||||
|
(nom, description, prompt, outils autorisés) + export/import
|
||||||
|
(`/skills/{id}/export`, `/skills/import`)
|
||||||
|
- [ ] **Modale « Parcourir les compétences »** — galerie `GET /api/agent/skills/gallery`,
|
||||||
|
installation en 1 clic, filtre
|
||||||
|
- [ ] **Tests** — CRUD, install/désinstall, pas de doublon à l'import
|
||||||
|
|
||||||
|
### Phase 3 — v7.53.0 — Design System – Canevas · effort M
|
||||||
|
|
||||||
|
- [ ] **Sous-menu Canevas** — `GET /api/page-templates` (built-ins + personnels)
|
||||||
|
- [ ] **Deux actions** — « Créer une page à partir du canevas »
|
||||||
|
(`POST /api/page-templates/{id}/use`) et « Insérer dans le document ouvert »
|
||||||
|
(blocs poussés dans l'éditeur quand un document est ouvert)
|
||||||
|
- [ ] **Canevas « design system »** — presets maison dans `block_templates.py`
|
||||||
|
(callout, TOC, colonnes, grille de composants) réutilisant `design-tokens.css`
|
||||||
|
- [ ] **Sauver le document ouvert comme canevas** — `POST /api/page-templates` existe
|
||||||
|
- [ ] **Tests** — création, insertion dans l'éditeur, sauvegarde
|
||||||
|
|
||||||
|
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M
|
||||||
|
|
||||||
|
- [ ] **Table `agent_memory`** (`workspace_id`, `kind` summary|fact, `content`,
|
||||||
|
`updated_at`) + colonne `conversations.memory_enabled`
|
||||||
|
- [ ] **Service `app/services/agent_memory.py`** — extraction en fin de run (résumé +
|
||||||
|
faits saillants ; LLM sans outils, repli déterministe hors-ligne)
|
||||||
|
- [ ] **Injection dans `ContextBuilder` si le toggle est ON** ; OFF = contexte strict
|
||||||
|
de la conversation courante
|
||||||
|
- [ ] **Toggle « Mémoire »** dans le menu + : état visible, persisté par conversation,
|
||||||
|
défaut `AGENT_MEMORY_DEFAULT`
|
||||||
|
- [ ] **Tests** — injection ON / absence OFF / persistance / budget de tokens borné
|
||||||
|
|
||||||
|
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L
|
||||||
|
|
||||||
|
- [ ] **Table `agent_connectors`** (`kind`, `name`, `config`, credentials chiffrés,
|
||||||
|
`enabled`, `status`)
|
||||||
|
- [ ] **`app/services/connectors/`** — interface commune `probe() / list_sources() / fetch()`
|
||||||
|
+ 3 adapters **natifs déjà présents** : Gitea, GitHub, Web (`web_search` / `fetch_url`, v7.46)
|
||||||
|
- [ ] **Menu « Connecteurs »** — « Parcourir les connecteurs » (catalogue + statut
|
||||||
|
connecté/déconnecté) et « Ajouter un connecteur personnalisé » (URL + clé + type
|
||||||
|
HTTP / MCP)
|
||||||
|
- [ ] **Tools agent** — un tool par connecteur exposé au LLM via `tool_registry`
|
||||||
|
- [ ] **Sécurité** — garde SSRF `_is_public_host` sur toute URL, credentials chiffrés,
|
||||||
|
session requise, CSRF sur les écritures
|
||||||
|
- [ ] **Tests** — CRUD, statut, vecteurs SSRF, schéma des tools
|
||||||
|
|
||||||
|
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L
|
||||||
|
|
||||||
|
- [ ] **OAuth2 PKCE Google** — Drive, Gmail, Calendar : connexion, refresh, déconnexion,
|
||||||
|
lecture limitée aux sources choisies
|
||||||
|
- [ ] **OAuth2 Microsoft (Graph)** — Outlook / OneDrive / SharePoint
|
||||||
|
- [ ] **Écran connecteur** — état, scopes, dernière synchro, bouton déconnecter
|
||||||
|
- [ ] **Tests** — callbacks, refresh, échec, **aucun appel réseau réel** (transport injecté)
|
||||||
|
|
||||||
|
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L
|
||||||
|
|
||||||
|
- [ ] **Discord** (bot token : lecture canaux/DMs), **Telegram** (Bot API),
|
||||||
|
**Teams** (via Graph de la phase 6)
|
||||||
|
- [ ] **MCP complet** — handshake `initialize`, `tools/list`, `tools/call`
|
||||||
|
(streamable HTTP) → outils **dynamiques** ajoutés au registre à la connexion
|
||||||
|
- [ ] **Tests** — schémas MCP, mapping d'outils, échecs d'auth
|
||||||
|
|
||||||
|
### Phase 8 — v7.58.0 — Add plugins · effort M
|
||||||
|
|
||||||
|
- [ ] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — état persisté
|
||||||
|
- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin
|
||||||
|
désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de
|
||||||
|
l'agent, web clipper, automations)
|
||||||
|
- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Plan produit le 2026-10-06 à partir du code réel — **phase 1 livrée le 2026-10-06
|
||||||
|
(v7.51.0)**, phases 2-8 à valider une par une avant « go ». Chaque phase se clôt par
|
||||||
|
tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔜 Prochaines versions
|
## 🔜 Prochaines versions
|
||||||
|
|
||||||
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **Début**: 2026-07-08 | **Version**: v7.49.2 (pages de base : titre au-dessus du tableau, tableau calé à gauche au ras du sidebar) | **Statut**: EN COURS 🔄
|
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
|
||||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||||
|
|
||||||
## Avancement Global
|
## Avancement Global
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.49.2",
|
version="7.51.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -217,8 +217,10 @@ def view_page_root(request: Request, page_id: int):
|
|||||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||||
).render(),
|
).render(),
|
||||||
)
|
)
|
||||||
# Select template: collection pages use database table view
|
# Select template: collection pages use database table view (aussi en
|
||||||
if page.get("content_format") == "collection" and not embed:
|
# mode embed : `page_editor_collection.html` + body.embed-mode = le
|
||||||
|
# tableau SANS sidebar ni barre — le peek d'une base reste le tableau).
|
||||||
|
if page.get("content_format") == "collection":
|
||||||
template = env.get_template("page_editor_collection.html")
|
template = env.get_template("page_editor_collection.html")
|
||||||
else:
|
else:
|
||||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class ContextBuilder:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _mentions_context(self, mentions: list[str]) -> str:
|
def _mentions_context(self, mentions: list[str]) -> str:
|
||||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
"""Resolve @document:x / @collection:x / @page:y / @folder:d / @repo:o/r mentions.
|
||||||
|
|
||||||
Mentions bring the *actual content* of the referenced object into the
|
Mentions bring the *actual content* of the referenced object into the
|
||||||
context so the LLM can summarise / rewrite / analyse it directly without
|
context so the LLM can summarise / rewrite / analyse it directly without
|
||||||
@@ -135,6 +135,8 @@ class ContextBuilder:
|
|||||||
elif m.startswith("page:"):
|
elif m.startswith("page:"):
|
||||||
pid = m.split(":", 1)[1]
|
pid = m.split(":", 1)[1]
|
||||||
lines.append(self._single_page(pid))
|
lines.append(self._single_page(pid))
|
||||||
|
elif m.startswith("folder:"):
|
||||||
|
lines.append(self._single_folder(m.split(":", 1)[1]))
|
||||||
elif m.startswith("repo:"):
|
elif m.startswith("repo:"):
|
||||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||||
elif m == "ws":
|
elif m == "ws":
|
||||||
@@ -209,6 +211,37 @@ class ContextBuilder:
|
|||||||
return f"{head}:\n{body[:9000]}"
|
return f"{head}:\n{body[:9000]}"
|
||||||
return head
|
return head
|
||||||
|
|
||||||
|
def _single_folder(self, fid: str) -> str:
|
||||||
|
"""Folder (répertoire) mention : titre du dossier + ses documents directs.
|
||||||
|
|
||||||
|
ponytail : enfants directs seulement, budget ~12k caractères —
|
||||||
|
ajouter une profondeur récursive si les arbres profonds deviennent réels.
|
||||||
|
"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||||
|
(fid,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return f"- dossier #{fid}: introuvable"
|
||||||
|
kids = conn.execute(
|
||||||
|
"SELECT id FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||||
|
"ORDER BY sort_order ASC, created_at DESC",
|
||||||
|
(fid,),
|
||||||
|
).fetchall()
|
||||||
|
title = row["title"] or "Sans titre"
|
||||||
|
head = f"- dossier #{row['id']} **{title}** ({len(kids)} élément(s))"
|
||||||
|
out = [head]
|
||||||
|
used = len(head)
|
||||||
|
for k in kids:
|
||||||
|
part = self._single_document(str(k["id"]))
|
||||||
|
if used + len(part) + 1 > 12000:
|
||||||
|
out.append("- … (contenu du dossier tronqué)")
|
||||||
|
break
|
||||||
|
used += len(part) + 1
|
||||||
|
out.append(part)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
def _single_collection(self, cid: str) -> str:
|
def _single_collection(self, cid: str) -> str:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||||
|
|||||||
@@ -125,21 +125,7 @@
|
|||||||
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Side Peek Panel for Database Pages (vanilla JS, piloté par database_table.js) -->
|
<!-- Le panneau de peek (#db-side-peek) est créé à la demande par
|
||||||
<div id="db-side-peek" class="db-side-peek">
|
database_table.js (ensureDbPeek) : une seule source de markup pour
|
||||||
<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>
|
la page de base et les bases embarquées dans un document. -->
|
||||||
<div class="db-side-peek-header">
|
|
||||||
<button id="db-side-peek-close" title="Close">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="db-side-peek-title" id="db-side-peek-title"></div>
|
|
||||||
<button id="db-side-peek-full" title="Open full page">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="db-side-peek-body">
|
|
||||||
<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>
|
|
||||||
<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
|||||||
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
|
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
|
||||||
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
|
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
|
||||||
|
|
||||||
|
/* Menu + (sections) — réutilise le skin du menu de mentions */
|
||||||
|
.fd-ap-plus-menu .fd-mention-item.disabled{opacity:.45;cursor:default;background:none}
|
||||||
|
.fd-ap-plus-menu .fd-mention-item.disabled:hover{background:none}
|
||||||
|
.fd-plus-chev{margin-left:auto;color:var(--text-dim,#999);opacity:.75;font-size:15px;flex-shrink:0}
|
||||||
|
.fd-plus-crumb{display:flex;gap:5px;flex-wrap:wrap;padding:7px 11px 3px;font-size:11px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
|
||||||
|
.fd-plus-crumb-it{color:var(--accent,#2383E2);cursor:pointer;white-space:nowrap}
|
||||||
|
.fd-plus-crumb-it:hover{text-decoration:underline}
|
||||||
|
|
||||||
/* Toast */
|
/* Toast */
|
||||||
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
|
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
|
||||||
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
|
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
|
||||||
@@ -380,6 +388,29 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fd-ap-mention-menu fd-ap-plus-menu" :class="{show: plusOpen}" x-ref="plusMenu">
|
||||||
|
<div class="fd-plus-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-item" :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>
|
||||||
</div>
|
</div>
|
||||||
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
|
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
|
||||||
<div class="fd-chip-row" x-show="mentionChips.length">
|
<div class="fd-chip-row" x-show="mentionChips.length">
|
||||||
@@ -406,7 +437,7 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
|||||||
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
|
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
|
||||||
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
|
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
|
||||||
<div class="fd-ap-toolbar">
|
<div class="fd-ap-toolbar">
|
||||||
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
|
<button class="fd-ap-add" type="button" title="Ajouter au contexte : fichiers, répertoires, compétences…" aria-label="Menu d'ajout au contexte" aria-haspopup="menu" :aria-expanded="plusOpen" @click="toggleAddPicker()">+</button>
|
||||||
<span class="fd-ap-tip" x-text="composerHint"></span>
|
<span class="fd-ap-tip" x-text="composerHint"></span>
|
||||||
<div class="fd-ap-tools">
|
<div class="fd-ap-tools">
|
||||||
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
|
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
|
||||||
|
|||||||
@@ -2093,9 +2093,16 @@
|
|||||||
case 'openTab':
|
case 'openTab':
|
||||||
window.open('/' + pageId, '_blank');
|
window.open('/' + pageId, '_blank');
|
||||||
break;
|
break;
|
||||||
case 'openPeek':
|
case 'openPeek': {
|
||||||
this.toast('Side peek not available in browser', 'info');
|
// Route vers le panneau de peek DE LA PAGE courante (même
|
||||||
|
// éditeur allégé `?embed=1` que le bouton « ↢ Open »), jamais une
|
||||||
|
// navigation. En dehors de ces pages : repli sur un nouvel onglet.
|
||||||
|
var num = pageId.replace('page/', '');
|
||||||
|
if (window._libData && window._libData.openPeekById) window._libData.openPeekById(parseInt(num));
|
||||||
|
else if (window._wsData && window._wsData.openSidePeek) window._wsData.openSidePeek(parseInt(num));
|
||||||
|
else window.open('/pages/' + num, '_blank');
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.contextMenu.visible = false;
|
this.contextMenu.visible = false;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
|
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
|
||||||
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
|
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
|
||||||
.peek-header button svg{width:15px;height:15px;}
|
.peek-header button svg{width:15px;height:15px;}
|
||||||
.peek-body{flex:1;overflow-y:auto;padding:16px 20px;}
|
.peek-body{flex:1;overflow:hidden;padding:0;}
|
||||||
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
||||||
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
|
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
|
||||||
|
|
||||||
@@ -450,8 +450,8 @@
|
|||||||
<!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en
|
<!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en
|
||||||
permanence, ce qui permet de piloter `src` en JS (le build CSP
|
permanence, ce qui permet de piloter `src` en JS (le build CSP
|
||||||
d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
|
d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
|
||||||
Charger `/pages/{id}` sans `?embed=1` : c'est exactement l'editeur
|
Charger `/pages/{id}?embed=1` : le peek affiche uniquement le
|
||||||
plein ecran, le peek n'est plus une variante allegee. -->
|
document en mode edition, sans sidebar ni barre de navigation. -->
|
||||||
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||||||
<div class="peek-loading" id="lib-peek-loading">Chargement…</div>
|
<div class="peek-loading" id="lib-peek-loading">Chargement…</div>
|
||||||
<iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
|
<iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
|
||||||
|
|||||||
@@ -8,4 +8,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% include '_page_editor_scripts.html' %}
|
{% include '_page_editor_scripts.html' %}
|
||||||
|
{# database_table.js = FlowDeckDB + le panneau de peek des bases : sans cet
|
||||||
|
include, une base embarquée dans un document restait bloquée à
|
||||||
|
« Loading database… » dans le peek et l'icône Open de fin de ligne ne
|
||||||
|
faisait rien. #}
|
||||||
|
{% include '_database_table_scripts.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# V74 — Menu + de l'assistant : hub de contexte (design)
|
||||||
|
|
||||||
|
> **Statut** : design — **phase 1 livrée en v7.51.0 (2026-10-06)** ; phases 2-8
|
||||||
|
> restent à livrer (le parcours utilise `GET /api/nav/menu?parent_id=` un niveau par
|
||||||
|
> appel, pas le tree récursif — contrat : dossier = `icon === 'folder'`).
|
||||||
|
> **Plan phasé** : `ROADMAP.md` → section « v7.51.0 → v7.58.0 — Menu + de l'assistant ».
|
||||||
|
> **Date** : 2026-10-06 · Version cible de départ : **v7.51.0** (phase 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Le bouton **+** du panneau agent (`app/templates/agent_panel.html:409`) ouvre
|
||||||
|
aujourd'hui une liste plate de fichiers/pages/bases à épingler en contexte. On le
|
||||||
|
transforme en **menu à sections à deux niveaux** :
|
||||||
|
|
||||||
|
```
|
||||||
|
+ ┌──────────────────────────────────────────────────────┐
|
||||||
|
│ 📎 Ajouter des fichiers ou répertoires › │ → picker actuel + « Parcourir… »
|
||||||
|
│ ✨ Compétences-skills › │ → Deep research, Skill-creator, …
|
||||||
|
│ │ ────────────────
|
||||||
|
│ │ Gérer les compétences
|
||||||
|
│ │ Parcourir les compétences
|
||||||
|
│ 🔌 Connecteurs › │ → Parcourir les connecteurs
|
||||||
|
│ │ Ajouter un connecteur personnalisé
|
||||||
|
│ 🎨 Design System – Canevas › │ → liste des canevas
|
||||||
|
│ 🧩 Add plugins │ → catalogue on/off (modale)
|
||||||
|
│ 🧠 Mémoire [◉/○] │ → toggle persisté
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Règle d'or : **chaque entrée vit dans la même phase qui la rend réelle**. Tant qu'une
|
||||||
|
section n'est pas livrée, elle s'affiche grisée « Bientôt (phase N) » — le menu ne
|
||||||
|
promet rien que le code ne fait.
|
||||||
|
|
||||||
|
## 2. État des lieux (lu dans le code, 2026-10-06)
|
||||||
|
|
||||||
|
| Section | Existe | Manque |
|
||||||
|
|---|---|---|
|
||||||
|
| Fichiers / répertoires | `GET /api/agent/mentions` (`app/routers/agent.py:596`) → documents, collections, pages ; jetons `document:id`, `collection:id`, `page:id` résolus par `ContextBuilder._mentions_context` (`app/services/context_builder.py:120`) | Sous-menu, mode parcours d'arborescence, jeton `folder:<id>` |
|
||||||
|
| Compétences | `agent_skills` CRUD (`/api/agent/skills` : GET/POST/DELETE, apply, import/export), galerie de 17 presets (`app/services/skill_gallery.py`), 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) | UI « Gérer » (CRUD complet : **`PATCH /skills/{id}` à créer**), UI « Parcourir » (la galerie n'est accessible que via la palette `/`), libellés affichés type « Deep research » |
|
||||||
|
| Canevas | `GET /api/page-templates` (built-ins `app/services/block_templates.py` + personnels), `POST /api/page-templates/{id}/use` (`app/routers/board/page_api.py:75,135`) | Menu dans le panneau agent, action « insérer dans le document ouvert », canevas « design system » |
|
||||||
|
| Connecteurs | Rien (0 hit `connector` dans `app/`). Natifs déjà branchés ailleurs : Gitea (`gitea_client.py`), GitHub (`github_adapter.py`), Web (`web_search.py` + tool `fetch_url` v7.46) | Table, service d'adapters, catalogue UI, tools agent, OAuth Google/M365, Discord/Telegram, MCP |
|
||||||
|
| Plugins | Rien | Registre, catalogue UI, **câblage réel** (désactivation effective) |
|
||||||
|
| Mémoire | Rien (l'historique de la conversation est déjà envoyé, ce n'est pas de la mémoire) | Table, service d'extraction, injection conditionnelle, toggle persisté |
|
||||||
|
|
||||||
|
## 3. Découpage front (Alpine)
|
||||||
|
|
||||||
|
Tout vit dans `static/js/agent_panel_2.js` (composant `agentPanel()`) + un bloc de
|
||||||
|
markup dans `app/templates/agent_panel.html`, sans nouveau fichier JS.
|
||||||
|
|
||||||
|
**État ajouté**
|
||||||
|
|
||||||
|
```js
|
||||||
|
plusOpen: false, // menu ouvert
|
||||||
|
plusSection: 'root', // 'root' | 'files' | 'skills' | 'connectors' | 'canvases'
|
||||||
|
plusFocus: -1, // index de l'item focalisé
|
||||||
|
plusItems: [], // items de la section courante (fetch si besoin)
|
||||||
|
memoryOn: true, // état du toggle (phase 4)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Items = données, pas du markup en dur** : un tableau `FD_PLUS_MENU`
|
||||||
|
`{key, icon, label, sub, action, phase}` → le rendu et la navigation clavier
|
||||||
|
(↑/↓/Entrée/Échap) sont une seule boucle. Les sections `phase > livrée` sont
|
||||||
|
`disabled: true`.
|
||||||
|
|
||||||
|
**Réemploi** : le conteneur et les styles `.fd-ap-mention-menu` /
|
||||||
|
`.fd-mention-item` (`agent_panel.html:171-181`) servent déjà exactement ce
|
||||||
|
comportement — on duplique le CSS minimal (`.fd-plus-*`) et on garde la logique de
|
||||||
|
focus/scroll (`moveMentionFocus`, `_scrollMentionItem`) comme modèle.
|
||||||
|
|
||||||
|
**Entrées existantes préservées** : la touche `@` dans le composer et le mode
|
||||||
|
recherche du `+` continuent de fonctionner à l'identique (régression zéro) ; le `+`
|
||||||
|
devient seulement *l'entrée en menu* au lieu d'ouvrir directement la liste.
|
||||||
|
|
||||||
|
## 4. Backend par phase
|
||||||
|
|
||||||
|
| Phase | Existant réutilisé | À créer |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 — menu + fichiers/répertoires | `/api/agent/mentions`, `/api/local-workspace/tree?folder=`, enfants de page (`parent_id`, `local_workspace.py:243`) | Route(s) de parcours adaptée(s) au panneau (shape items compatible mention), jeton `folder:<id>` dans `ContextBuilder` |
|
||||||
|
| 2 — compétences | `/api/agent/skills` (GET/POST/DELETE/apply/import/export), `/api/agent/skills/gallery` + install | `PATCH /api/agent/skills/{id}` (édition), 2 modales |
|
||||||
|
| 3 — canevas | `/api/page-templates` (GET/POST/use) | Insersion dans l'éditeur ouvert (API d'insertion de blocs de l'éditeur), presets « design system » dans `block_templates.py` |
|
||||||
|
| 4 — mémoire | `ContextBuilder`, `PATCH /conversations/{id}` (pattern du toggle) | Table `agent_memory`, `app/services/agent_memory.py`, colonne `conversations.memory_enabled`, config `AGENT_MEMORY_DEFAULT` |
|
||||||
|
| 5 — connecteurs (socle) | `_is_public_host` (SSRF), `encrypt_secret` (`sso_provisioning.py:51`), `http_client.shared_client`, `tool_registry` | Table `agent_connectors`, `app/services/connectors/`, 3 adapters natifs (gitea/github/web), catalogue UI |
|
||||||
|
| 6 — Google + M365 | `auth/oauth.py` (client OAuth2), `encrypt_secret`, `shared_client` | Flows PKCE Google + Microsoft Graph, table de tokens (motif `calendar_sync.py:38`), écran connecteur |
|
||||||
|
| 7 — Discord / Telegram / Teams / MCP | Graph (phase 6), pattern de tools dynamiques du registre | 3 adapters + client MCP (`initialize`, `tools/list`, `tools/call`) |
|
||||||
|
| 8 — plugins | — | Table `plugins`, catalogue UI, désactivation réelle (routes + UI) |
|
||||||
|
|
||||||
|
## 5. Modèle de données
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Phase 4 — mémoire
|
||||||
|
ALTER TABLE conversations ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1;
|
||||||
|
CREATE TABLE agent_memory (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
workspace_id INTEGER NOT NULL,
|
||||||
|
conversation_id INTEGER, -- NULL = mémoire d'espace (partagée)
|
||||||
|
kind TEXT NOT NULL DEFAULT 'summary', -- summary | fact
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_agent_memory_ws ON agent_memory(workspace_id, conversation_id);
|
||||||
|
|
||||||
|
-- Phase 5 — connecteurs
|
||||||
|
CREATE TABLE agent_connectors (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
workspace_id INTEGER,
|
||||||
|
kind TEXT NOT NULL, -- gitea | github | web | google | ms365 | discord | telegram | mcp | custom
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}', -- URL, scopes, channels…
|
||||||
|
secret_encrypted TEXT, -- Fernet (encrypt_secret, sso_provisioning.py:51)
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown', -- ok | error | unknown
|
||||||
|
last_probe_at TEXT,
|
||||||
|
created_by INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 8 — plugins
|
||||||
|
CREATE TABLE plugins (
|
||||||
|
slug TEXT PRIMARY KEY, -- web-tools | web-clipper | automations | …
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations ajoutées dans `app/migrations.py` (motif `_migration_*` existant,
|
||||||
|
transaction par migration — A31).
|
||||||
|
|
||||||
|
## 6. Sécurité (non négociable)
|
||||||
|
|
||||||
|
- **SSRF** : toute URL de connecteur passée par `_is_public_host`
|
||||||
|
(`app/services/importers/url_fetch.py`), re-vérifiée à chaque saut de redirection.
|
||||||
|
- **Secrets** : jamais en clair, jamais renvoyés par l'API (champ retiré des
|
||||||
|
sérialiseurs) — `encrypt_secret()` existant, dérivé de `app_secret_key`.
|
||||||
|
- **Auth + CSRF** : session requise sur tout CUD ; `/api/agent` est sur la liste des
|
||||||
|
préfixes CSRF cookie → header `X-CSRF-Token` sur les nouveaux `fetch` (helper
|
||||||
|
`getCsrf()`).
|
||||||
|
- **Borne de tokens** : la mémoire et les sources de connecteurs injectées dans le
|
||||||
|
contexte sont tronquées (budget explicite) — sinon la phase 4 fait exploser le
|
||||||
|
coût de chaque run.
|
||||||
|
- **Plugins OFF = effet réel** : un simple drapeau affiché serait un menu qui ment
|
||||||
|
(règle du projet).
|
||||||
|
|
||||||
|
## 7. Décisions techniques
|
||||||
|
|
||||||
|
- **D1 — Un seul menu, deux niveaux** (pas de modale racine) : ouverture instantanée,
|
||||||
|
clavier, et le `+` reste un geste unique. Les modales ne servent qu'aux écrans
|
||||||
|
lourds (Gérer / Parcourir / Connecteurs / Plugins).
|
||||||
|
- **D2 — Pas de nouveau fichier JS** : tout dans `agent_panel_2.js` + markup du
|
||||||
|
template, conforme à l'extraction A27 (un fichier par bloc).
|
||||||
|
- **D3 — États « Bientôt » visibles** plutôt que sections masquées : le menu sert de
|
||||||
|
spec vivante et évite d'implémenter6 features d'un coup.
|
||||||
|
- **D4 — La mémoire est un résumé structuré, pas l'historique brut** : réinvoquer
|
||||||
|
tout l'historique à chaque run coûterait plus cher que la réponse ; un résumé borné
|
||||||
|
(~1–2 k tokens) + les faits saillants suffisent, avec repli déterministe hors-ligne
|
||||||
|
(motif `ai_writing.py`).
|
||||||
|
- **D5 — Connecteurs = registry + adapters** (interface `probe/list_sources/fetch`),
|
||||||
|
pas un if par provider : la phase 7 (MCP) ajoute des adapters sans toucher au socle.
|
||||||
|
- **D6 — Canevas : « créer » par défaut, « insérer » seulement si un document est
|
||||||
|
ouvert** — l'insertion dans l'éditeur est le point le plus fragile (blocs +
|
||||||
|
sélection), donc livrée en second dans la phase 3.
|
||||||
|
- **D7 — Pas de dépendance ajoutée** : OAuth via `httpx` (`shared_client`), chiffrement
|
||||||
|
via `cryptography` déjà présent, MCP en HTTP direct.
|
||||||
|
|
||||||
|
## 8. Pièges du repo à respecter
|
||||||
|
|
||||||
|
- `ROADMAP.md` / `CHANGELOG.md` sont en **CRLF** : `patch` exige des lignes complètes.
|
||||||
|
- **Routes statiques avant les wildcards** (`/skills/gallery` avant `/skills/{id}`),
|
||||||
|
sinon 422 sur un id non numérique.
|
||||||
|
- **Events `AgentEngine.run()` plats** (`{"type": …, "content": …}`), pas enveloppés
|
||||||
|
dans `data`.
|
||||||
|
- Bumper **`VERSION` + `app/main.py`** à chaque phase, régénérer
|
||||||
|
`docs/openapi-v2.json` (CRLF, sans newline final) si de nouvelles routes sortent.
|
||||||
|
- Le panneau agent est rendu sur **toutes les pages** (`base.html:2555`) : le JS doit
|
||||||
|
rester défensif (`window.appState` absent, page non authentifiée).
|
||||||
|
|
||||||
|
## 9. Critères d'acceptation par phase
|
||||||
|
|
||||||
|
1. **Phase 1** : le `+` ouvre le menu, « Parcourir… » montre l'arborescence, un
|
||||||
|
dossier épinglé apparaît en chip et son contenu est bien dans le contexte du run.
|
||||||
|
2. **Phase 2** : créer/éditer/supprimer un skill depuis la modale, l'installer depuis
|
||||||
|
la galerie, il apparaît dans la palette `/` et le sous-menu.
|
||||||
|
3. **Phase 3** : choisir un canevas crée la page (et l'insertion dans le document
|
||||||
|
ouvert fonctionne quand un doc est édité).
|
||||||
|
4. **Phase 4** : ON → la mémoire est injectée (test qui le prouve) ; OFF → absente ;
|
||||||
|
l'état survit au rechargement et au changement de conversation.
|
||||||
|
5. **Phase 5** : connecteur créé/testé/supprimé, URL privée refusée (SSRF), tool
|
||||||
|
exposé au LLM avec le bon schéma.
|
||||||
|
6. **Phase 6** : connexion Google et Microsoft, refresh, déconnexion, aucun appel
|
||||||
|
réseau réel dans les tests.
|
||||||
|
7. **Phase 7** : Discord/Telegram branchés, MCP `tools/list` → outils visibles dans le
|
||||||
|
registre.
|
||||||
|
8. **Phase 8** : plugin OFF = route refusée + UI disparue (test de non-réintroduction).
|
||||||
|
|
||||||
|
Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, ROADMAP
|
||||||
|
à jour, tag.
|
||||||
|
|
||||||
|
## 10. Hors périmètre
|
||||||
|
|
||||||
|
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
|
||||||
|
- Synchronisation d'arborescence locale ↔ connecteur.
|
||||||
|
- Marketplace de plugins tiers (le registre est interne à l'instance).
|
||||||
|
- Refonte du composer / de la palette `/` (compatibilité maintenue à l'identique).
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.49.2"
|
"version": "7.50.2"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
|
|||||||
+7
-6
@@ -2246,13 +2246,15 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
|
|
||||||
/* Page de base (premier bloc = embed collection) : le titre et le tableau
|
/* Page de base (premier bloc = embed collection) : le titre et le tableau
|
||||||
s'alignent sur la marge gauche de la topbar (16px) au lieu d'être centrés
|
s'alignent sur la marge gauche de la topbar (16px) au lieu d'être centrés
|
||||||
dans la colonne 900px — le tableau démarre au ras du sidebar. */
|
dans la colonne 900px — le tableau démarre au ras du sidebar et prend la
|
||||||
|
LARGEUR DU DOCUMENT (les colonnes dictent le scroll horizontal). */
|
||||||
.page-editor-wrapper.db-first .page-cover-area,
|
.page-editor-wrapper.db-first .page-cover-area,
|
||||||
.page-editor-wrapper.db-first .page-title-block,
|
.page-editor-wrapper.db-first .page-title-block,
|
||||||
.page-editor-wrapper.db-first .blocks-container {
|
.page-editor-wrapper.db-first .blocks-container {
|
||||||
max-width: 1100px;
|
max-width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: var(--space-lg);
|
padding-left: var(--space-lg);
|
||||||
|
padding-right: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Une base embarquée ne doit jamais déborder de sa colonne de blocs :
|
/* Une base embarquée ne doit jamais déborder de sa colonne de blocs :
|
||||||
@@ -2262,7 +2264,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
/* Titre des pages de base dédiées (page_editor_collection.html) — même
|
/* Titre des pages de base dédiées (page_editor_collection.html) — même
|
||||||
liseré gauche que .database-table-container. */
|
liseré gauche que .database-table-container. */
|
||||||
.database-page-title {
|
.database-page-title {
|
||||||
max-width: 1100px;
|
max-width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 28px var(--space-lg) 0;
|
padding: 28px var(--space-lg) 0;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
@@ -3899,11 +3901,10 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
═══════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* Base = viewport du contenu ; les vues tabulaires larges (bases de
|
/* Base = viewport du contenu ; les vues tabulaires larges (bases de
|
||||||
données) s'alignent à gauche, au ras de la marge, à la même largeur
|
données) prennent la LARGEUR DU DOCUMENT (les colonnes dictent le scroll
|
||||||
que Library (1100px). */
|
horizontal), alignées à gauche au ras de la marge. */
|
||||||
.database-table-container {
|
.database-table-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
/* var(--space-lg) = padding de .topbar : le bord gauche du tableau
|
/* var(--space-lg) = padding de .topbar : le bord gauche du tableau
|
||||||
s'aligne sur le titre du fil d'ariane, au ras de la marge. */
|
s'aligne sur le titre du fil d'ariane, au ras de la marge. */
|
||||||
|
|||||||
@@ -207,6 +207,14 @@
|
|||||||
}
|
}
|
||||||
.mt-card:active { cursor: grabbing; opacity: .7; }
|
.mt-card:active { cursor: grabbing; opacity: .7; }
|
||||||
.mt-card-title { font-weight: 500; }
|
.mt-card-title { font-weight: 500; }
|
||||||
|
/* Bouton « Open » en overlay au survol de la tuile (la carte est une
|
||||||
|
colonne flex : une place en flux déformerait la tuile). */
|
||||||
|
.mt-card { position: relative; }
|
||||||
|
.mt-card .mt-open-btn {
|
||||||
|
position: absolute; top: 6px; right: 6px; margin-left: 0;
|
||||||
|
}
|
||||||
|
.mt-card:hover .mt-hover-only,
|
||||||
|
.mt-card:focus-within .mt-hover-only { opacity: 1; }
|
||||||
.mt-card-due,
|
.mt-card-due,
|
||||||
.mt-card-src { font-size: 11px; color: var(--text-dim, #999); }
|
.mt-card-src { font-size: 11px; color: var(--text-dim, #999); }
|
||||||
.mt-card-src { opacity: .8; }
|
.mt-card-src { opacity: .8; }
|
||||||
|
|||||||
+146
-3
@@ -32,6 +32,28 @@
|
|||||||
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack',
|
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack',
|
||||||
template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'}
|
template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'}
|
||||||
];
|
];
|
||||||
|
// ── Menu + (v7.51.0) : sections à deux niveaux. `soon` = section pas encore
|
||||||
|
// livrée → affichée mais non cliquable (le menu ne promet rien que le code ne fait).
|
||||||
|
var FD_PLUS_MENU = [
|
||||||
|
{key:'files', icon:'📎', label:'Ajouter des fichiers ou répertoires',
|
||||||
|
sub:'Rechercher ou parcourir le workspace', action:'files', chev:true},
|
||||||
|
{key:'skills', icon:'✨', label:'Compétences-skills',
|
||||||
|
sub:'Deep research, Skill-creator… — bientôt (phase 2)', disabled:true},
|
||||||
|
{key:'connectors', icon:'🔌', label:'Connecteurs',
|
||||||
|
sub:'Parcourir / connecteur personnalisé — bientôt (phase 5)', disabled:true},
|
||||||
|
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
|
||||||
|
sub:'Galerie de canevas — bientôt (phase 3)', disabled:true},
|
||||||
|
{key:'plugins', icon:'🧩', label:'Add plugins',
|
||||||
|
sub:'Modules on/off — bientôt (phase 8)', disabled:true},
|
||||||
|
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
|
||||||
|
sub:'Mémoire persistante — bientôt (phase 4)', disabled:true}
|
||||||
|
];
|
||||||
|
var FD_PLUS_FILES = [
|
||||||
|
{key:'f-search', icon:'🔍', label:'Rechercher…',
|
||||||
|
sub:'Documents, bases, pages par nom', action:'search', chev:true},
|
||||||
|
{key:'f-browse', icon:'📁', label:'Parcourir…',
|
||||||
|
sub:'Dossiers et fichiers du workspace', action:'browse', chev:true}
|
||||||
|
];
|
||||||
|
|
||||||
function agentPanel(){
|
function agentPanel(){
|
||||||
return {
|
return {
|
||||||
@@ -45,6 +67,8 @@
|
|||||||
contextChips: [], skills: [], gallery: [],
|
contextChips: [], skills: [], gallery: [],
|
||||||
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
|
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
|
||||||
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
||||||
|
plusOpen: false, plusSection: 'root', plusFocus: -1, plusLoading: false,
|
||||||
|
plusBrowse: [], plusCrumb: [],
|
||||||
cmdFocus: -1, slashDismiss: false,
|
cmdFocus: -1, slashDismiss: false,
|
||||||
toastMsg: '', toastErr: false, _toastTimer: null,
|
toastMsg: '', toastErr: false, _toastTimer: null,
|
||||||
_livePh: null,
|
_livePh: null,
|
||||||
@@ -1070,12 +1094,117 @@
|
|||||||
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
|
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Mentions & contexte épinglé (@ / +) ──
|
// ── Menu + (sections) & mentions (@ / recherche) ──
|
||||||
toggleAddPicker(){
|
toggleAddPicker(){
|
||||||
if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; }
|
if(this.mentionOpen){
|
||||||
this.openMentionPicker('+');
|
this.mentionOpen = false; this.mentionQuery = '';
|
||||||
|
this._mentionFrom = null; this._atNode = null;
|
||||||
|
}
|
||||||
|
if(this.plusOpen){ this.closePlus(); return; }
|
||||||
|
this.plusOpen = true;
|
||||||
|
this.plusSection = 'root';
|
||||||
|
this.plusFocus = 0;
|
||||||
|
},
|
||||||
|
closePlus(){
|
||||||
|
this.plusOpen = false;
|
||||||
|
this.plusSection = 'root';
|
||||||
|
this.plusCrumb = [];
|
||||||
|
this.plusBrowse = [];
|
||||||
|
this.plusFocus = -1;
|
||||||
|
},
|
||||||
|
get plusTitle(){
|
||||||
|
if(this.plusSection === 'files') return 'Fichiers & répertoires';
|
||||||
|
if(this.plusSection === 'browse') return 'Parcourir le workspace';
|
||||||
|
return 'Ajouter au contexte';
|
||||||
|
},
|
||||||
|
get plusItems(){
|
||||||
|
if(this.plusSection === 'files') return FD_PLUS_FILES;
|
||||||
|
if(this.plusSection === 'browse') return this.plusBrowse;
|
||||||
|
return FD_PLUS_MENU;
|
||||||
|
},
|
||||||
|
_plusSelectable(){
|
||||||
|
var out = [];
|
||||||
|
this.plusItems.forEach(function(it, i){ if(!it.disabled) 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) 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;
|
||||||
|
}
|
||||||
|
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(){
|
||||||
|
if(this.plusSection === 'browse' && this.plusCrumb.length){
|
||||||
|
this.plusCrumb.pop();
|
||||||
|
this.plusLoadBrowse(this.plusCrumb.length ? this.plusCrumb[this.plusCrumb.length - 1].id : null);
|
||||||
|
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; });
|
||||||
},
|
},
|
||||||
openMentionPicker(from){
|
openMentionPicker(from){
|
||||||
|
this.plusOpen = false;
|
||||||
this.mentionOpen = true;
|
this.mentionOpen = true;
|
||||||
this._mentionFrom = from || '+';
|
this._mentionFrom = from || '+';
|
||||||
this.mentionQuery = '';
|
this.mentionQuery = '';
|
||||||
@@ -1177,6 +1306,7 @@
|
|||||||
onInput(){
|
onInput(){
|
||||||
this.resizeInput();
|
this.resizeInput();
|
||||||
this.slashDismiss = false;
|
this.slashDismiss = false;
|
||||||
|
if(this.plusOpen) this.closePlus(); // tape = on écrit le message, le menu se referme
|
||||||
var text = this.composerText();
|
var text = this.composerText();
|
||||||
this.input = text;
|
this.input = text;
|
||||||
var cmdNow = /^\s*\//.test(text);
|
var cmdNow = /^\s*\//.test(text);
|
||||||
@@ -1249,16 +1379,29 @@
|
|||||||
}catch { /* volontaire */ }
|
}catch { /* volontaire */ }
|
||||||
}
|
}
|
||||||
if(key === 'Escape'){
|
if(key === 'Escape'){
|
||||||
|
if(this.plusOpen){ e.preventDefault(); e.stopPropagation(); this.closePlus(); return; }
|
||||||
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
|
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
|
||||||
if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
|
if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
|
||||||
return; // laisse fermer le panneau
|
return; // laisse fermer le panneau
|
||||||
}
|
}
|
||||||
if(key === 'ArrowDown' || key === 'ArrowUp'){
|
if(key === 'ArrowDown' || key === 'ArrowUp'){
|
||||||
var delta = key === 'ArrowDown' ? 1 : -1;
|
var delta = key === 'ArrowDown' ? 1 : -1;
|
||||||
|
if(this.plusOpen){ e.preventDefault(); this.movePlusFocus(delta); return; }
|
||||||
if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
|
if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
|
||||||
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
|
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Entrée / Tab avec le menu + ouvert
|
||||||
|
if(this.plusOpen){
|
||||||
|
if(key === 'Enter' || key === 'Tab'){
|
||||||
|
e.preventDefault();
|
||||||
|
var psel = this._plusSelectable();
|
||||||
|
if(!psel.length) return;
|
||||||
|
var pidx = psel.indexOf(this.plusFocus) >= 0 ? this.plusFocus : psel[0];
|
||||||
|
this.plusAction(this.plusItems[pidx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Entrée / Tab avec un menu ouvert (skills / mentions)
|
// Entrée / Tab avec un menu ouvert (skills / mentions)
|
||||||
if(this.mentionOpen){
|
if(this.mentionOpen){
|
||||||
var mSel = this._selectableMentionItems();
|
var mSel = this._selectableMentionItems();
|
||||||
|
|||||||
+75
-61
@@ -1283,72 +1283,86 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Side peek : panneau latéral des lignes de base (vanilla JS) ──
|
// ── Side peek : panneau latéral des lignes de base (vanilla JS) ──
|
||||||
// Le panneau vit dans _database_table.html (page de base ET bases
|
// Le panneau vit dans _database_table.html (page de base). Une base
|
||||||
// embarquées inline). Il doit être câblé SUR le document, jamais via
|
// EMBARQUÉE dans un document n'a pas ce markup : on le crée à la demande
|
||||||
// container.__x : le conteneur n'a pas de x-data (les bindings Alpine
|
// (comme mt-peek de My Tasks), sinon l'icône Open de fin de ligne était
|
||||||
// du panneau restaient inertes — clic sur une ligne = rien).
|
// un no-op silencieux. Il doit être câblé SUR le document, jamais via
|
||||||
var peekEl = document.getElementById('db-side-peek');
|
// container.__x : le conteneur n'a pas de x-data.
|
||||||
// Sans panneau (collections embarquées inline dans l'éditeur), le clic ne
|
var PEEK_HTML =
|
||||||
// doit pas lever de ReferenceError : repli discret, comme l'ancien code.
|
'<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>' +
|
||||||
if (!peekEl) window.openPageInSidePeek = function() {};
|
'<div class="db-side-peek-header">' +
|
||||||
if (peekEl) {
|
'<button id="db-side-peek-close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>' +
|
||||||
|
'<div class="db-side-peek-title" id="db-side-peek-title"></div>' +
|
||||||
|
'<button id="db-side-peek-full" title="Open full page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="db-side-peek-body">' +
|
||||||
|
'<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>' +
|
||||||
|
'<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>' +
|
||||||
|
'</div>';
|
||||||
|
function ensureDbPeek() {
|
||||||
|
var p = document.getElementById('db-side-peek');
|
||||||
|
if (p) return p;
|
||||||
|
p = document.createElement('div');
|
||||||
|
p.id = 'db-side-peek';
|
||||||
|
p.className = 'db-side-peek';
|
||||||
|
p.innerHTML = PEEK_HTML;
|
||||||
|
document.body.appendChild(p);
|
||||||
|
document.getElementById('db-side-peek-close').addEventListener('click', closeDbPeek);
|
||||||
|
document.getElementById('db-side-peek-full').addEventListener('click', function () {
|
||||||
|
var f = document.getElementById('db-side-peek-iframe');
|
||||||
|
// `dataset.page` et non `iframe.src` : le src porte `?embed=1`,
|
||||||
|
// la pleine page doit garder sa sidebar et sa barre.
|
||||||
|
if (f && f.dataset.page) window.location.href = '/pages/' + f.dataset.page;
|
||||||
|
});
|
||||||
|
if (window.fdWirePeekResize) window.fdWirePeekResize(p, p.querySelector('.peek-resize-handle'), closeDbPeek);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
function closeDbPeek() {
|
||||||
|
var p = document.getElementById('db-side-peek');
|
||||||
|
if (!p) return;
|
||||||
|
p.classList.remove('open');
|
||||||
|
document.getElementById('db-side-peek-iframe').src='about:blank';
|
||||||
|
var t = document.getElementById('db-side-peek-title');
|
||||||
|
if (t) t.textContent = '';
|
||||||
|
}
|
||||||
|
window.openPageInSidePeek = function(pageId) {
|
||||||
|
if (!pageId) return;
|
||||||
|
var peekEl = ensureDbPeek();
|
||||||
var peekFrame = document.getElementById('db-side-peek-iframe');
|
var peekFrame = document.getElementById('db-side-peek-iframe');
|
||||||
var peekTitle = document.getElementById('db-side-peek-title');
|
var peekTitle = document.getElementById('db-side-peek-title');
|
||||||
var peekLoading = document.getElementById('db-side-peek-loading');
|
var peekLoading = document.getElementById('db-side-peek-loading');
|
||||||
function closeDbPeek() {
|
peekEl.classList.add('open');
|
||||||
peekEl.classList.remove('open');
|
if (peekLoading) peekLoading.style.display = '';
|
||||||
document.getElementById('db-side-peek-iframe').src='about:blank';
|
if (peekFrame) peekFrame.src = 'about:blank';
|
||||||
if (peekTitle) { peekTitle.textContent = ''; }
|
if (peekTitle) peekTitle.textContent = '';
|
||||||
}
|
resolveRowContentPage(pageId).then(function(pid){
|
||||||
window.openPageInSidePeek = function(pageId) {
|
|
||||||
if (!pageId) return;
|
|
||||||
peekEl.classList.add('open');
|
|
||||||
if (peekLoading) peekLoading.style.display = '';
|
|
||||||
var iframe = document.getElementById('db-side-peek-iframe');
|
|
||||||
if (iframe) iframe.src='about:blank';
|
|
||||||
if (peekTitle) peekTitle.textContent = '';
|
|
||||||
resolveRowContentPage(pageId).then(function(pid){
|
|
||||||
if (!peekEl.classList.contains('open')) return;
|
|
||||||
if (peekTitle) peekTitle.textContent = '';
|
|
||||||
if (peekLoading) peekLoading.style.display = 'none';
|
|
||||||
if (iframe) iframe.src = '/pages/'+pid;
|
|
||||||
})
|
|
||||||
.catch(function(){
|
|
||||||
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
|
|
||||||
// CSP d'Alpine les toasts ne s'affichent pas partout.
|
|
||||||
if (iframe) {
|
|
||||||
iframe.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
|
|
||||||
}
|
|
||||||
peekEl.classList.add('open');
|
|
||||||
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
var pc = document.getElementById('db-side-peek-close');
|
|
||||||
if (pc) pc.addEventListener('click', closeDbPeek);
|
|
||||||
var pf = document.getElementById('db-side-peek-full');
|
|
||||||
if (pf) pf.addEventListener('click', function() {
|
|
||||||
if (peekFrame && peekFrame.src && peekFrame.src !== 'about:blank') {
|
|
||||||
window.location.href = peekFrame.src;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape' && peekEl.classList.contains('open')) closeDbPeek();
|
|
||||||
});
|
|
||||||
document.addEventListener('pointerdown', function(e) {
|
|
||||||
if (!peekEl.classList.contains('open')) return;
|
if (!peekEl.classList.contains('open')) return;
|
||||||
if (peekEl.contains(e.target)) return;
|
if (peekLoading) peekLoading.style.display = 'none';
|
||||||
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
|
// `?embed=1` : uniquement le document edite, pas l'habillage
|
||||||
closeDbPeek();
|
// (sidebar + barre) — la pleine page reste via le bouton « full ».
|
||||||
});
|
if (peekFrame) { peekFrame.dataset.page = pid; peekFrame.src = '/pages/'+pid+'?embed=1'; }
|
||||||
function wireDbPeekResize() {
|
})
|
||||||
if (window.fdWirePeekResize) {
|
.catch(function(){
|
||||||
window.fdWirePeekResize(peekEl, peekEl.querySelector('.peek-resize-handle'), closeDbPeek);
|
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
|
||||||
} else if (document.readyState === 'loading') {
|
// CSP d'Alpine les toasts ne s'affichent pas partout.
|
||||||
document.addEventListener('DOMContentLoaded', wireDbPeekResize);
|
if (peekFrame) {
|
||||||
|
peekFrame.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
|
||||||
}
|
}
|
||||||
}
|
peekEl.classList.add('open');
|
||||||
wireDbPeekResize();
|
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
|
||||||
}
|
});
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
var p = document.getElementById('db-side-peek');
|
||||||
|
if (e.key === 'Escape' && p && p.classList.contains('open')) closeDbPeek();
|
||||||
|
});
|
||||||
|
document.addEventListener('pointerdown', function(e) {
|
||||||
|
var p = document.getElementById('db-side-peek');
|
||||||
|
if (!p || !p.classList.contains('open')) return;
|
||||||
|
if (p.contains(e.target)) return;
|
||||||
|
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
|
||||||
|
closeDbPeek();
|
||||||
|
});
|
||||||
|
|
||||||
function resolveRowContentPage(rowId){
|
function resolveRowContentPage(rowId){
|
||||||
return fetch('/db/pages/'+rowId+'/open/api')
|
return fetch('/db/pages/'+rowId+'/open/api')
|
||||||
|
|||||||
@@ -979,9 +979,9 @@ _renderIcon(item) {
|
|||||||
if (loading) loading.style.display = '';
|
if (loading) loading.style.display = '';
|
||||||
frame.onload = function () { if (loading) loading.style.display = 'none'; };
|
frame.onload = function () { if (loading) loading.style.display = 'none'; };
|
||||||
frame.src = 'about:blank';
|
frame.src = 'about:blank';
|
||||||
// `/pages/{id}` sans `?embed=1` : le peek affiche le MÊME éditeur que la
|
// `?embed=1` : le peek affiche SEULEMENT le document en mode édition
|
||||||
// page pleine (sidebar, barre, actions compris), pas une variante.
|
// (ni sidebar ni barre de navigation) — le cadre du panneau suffit.
|
||||||
frame.src = '/pages/' + item.id;
|
frame.src = '/pages/' + item.id + '?embed=1';
|
||||||
},
|
},
|
||||||
|
|
||||||
closePeek() {
|
closePeek() {
|
||||||
|
|||||||
@@ -395,12 +395,12 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
|||||||
window.showToast('Failed to copy', 'error');
|
window.showToast('Failed to copy', 'error');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// Load the full-screen editor in the iframe (no `?embed=1`: the peek
|
// Load ONLY the editable document in the iframe (`?embed=1`: no
|
||||||
// shows the same editor as the page itself). `about:blank` first, so
|
// sidebar, no navbar). `about:blank` first, so opening another
|
||||||
// opening another document really reloads the frame instead of keeping
|
// document really reloads the frame instead of keeping the previous
|
||||||
// the previous one when the URL happens to be identical.
|
// one when the URL happens to be identical.
|
||||||
var iframe = document.getElementById('ws-preview-iframe');
|
var iframe = document.getElementById('ws-preview-iframe');
|
||||||
if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id; }
|
if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id + '?embed=1'; }
|
||||||
// Show panel
|
// Show panel
|
||||||
panel.style.display = 'flex';
|
panel.style.display = 'flex';
|
||||||
requestAnimationFrame(function() {
|
requestAnimationFrame(function() {
|
||||||
|
|||||||
+12
-6
@@ -152,9 +152,10 @@ function flowDeckMyTasks() {
|
|||||||
f.style.display = '';
|
f.style.display = '';
|
||||||
// `about:blank` d'abord : ouvrir une autre tâche doit REcharger
|
// `about:blank` d'abord : ouvrir une autre tâche doit REcharger
|
||||||
// l'éditeur, sinon l'URL identique laisse le document précédent
|
// l'éditeur, sinon l'URL identique laisse le document précédent
|
||||||
// affiché. `/pages/{id}` sans `?embed=1` = l'éditeur plein écran.
|
// affiché. `?embed=1` = le document en édition seule, sans
|
||||||
|
// sidebar ni barre de navigation (le cadre du panneau suffit).
|
||||||
f.src = 'about:blank';
|
f.src = 'about:blank';
|
||||||
f.src = '/pages/' + pid;
|
f.src = '/pages/' + pid + '?embed=1';
|
||||||
}).catch(function (e) {
|
}).catch(function (e) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message;
|
loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message;
|
||||||
@@ -389,6 +390,7 @@ function flowDeckMyTasks() {
|
|||||||
h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' +
|
h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' +
|
||||||
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
|
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
|
||||||
'<span class="mt-card-title">' + esc(t.title) + '</span>' +
|
'<span class="mt-card-title">' + esc(t.title) + '</span>' +
|
||||||
|
openButton(t) +
|
||||||
(t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' +
|
(t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' +
|
||||||
esc(t.due) + '</span>' : '') +
|
esc(t.due) + '</span>' : '') +
|
||||||
'<span class="mt-card-src">' + esc(t.collection_icon) + ' ' +
|
'<span class="mt-card-src">' + esc(t.collection_icon) + ' ' +
|
||||||
@@ -878,17 +880,21 @@ function flowDeckMyTasks() {
|
|||||||
var t = e.target;
|
var t = e.target;
|
||||||
|
|
||||||
if (t.id === 'mt-new-btn') { openNewModal(); return; }
|
if (t.id === 'mt-new-btn') { openNewModal(); return; }
|
||||||
if (t.dataset && t.dataset.openRow) {
|
var opener = t.closest ? t.closest('[data-open-row]') : null;
|
||||||
|
if (opener) {
|
||||||
|
// `closest` : dans le calendrier le bouton ne contient que son
|
||||||
|
// SVG — un clic sur l'icone donnait `e.target` = <svg> sans
|
||||||
|
// dataset, et le bouton ne faisait rien.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Le titre vient de l'attribut : il est disponible aussi bien dans
|
// Le titre vient de l'attribut : il est disponible aussi bien dans
|
||||||
// le tableau que dans le calendrier ou la vue Jour.
|
// le tableau que dans le calendrier ou la vue Jour.
|
||||||
var label = t.dataset.openTitle;
|
var label = opener.dataset.openTitle;
|
||||||
if (!label) {
|
if (!label) {
|
||||||
var row = t.closest('.mt-row');
|
var row = opener.closest('.mt-row');
|
||||||
var cell = row && row.querySelector('.mt-title-text');
|
var cell = row && row.querySelector('.mt-title-text');
|
||||||
label = cell ? cell.textContent : '';
|
label = cell ? cell.textContent : '';
|
||||||
}
|
}
|
||||||
openRowContent(Number(t.dataset.openRow), label);
|
openRowContent(Number(opener.dataset.openRow), label);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (t.dataset && t.dataset.fixSource) {
|
if (t.dataset && t.dataset.fixSource) {
|
||||||
|
|||||||
@@ -1388,13 +1388,35 @@ def test_library_peek_iframe_is_not_inside_the_x_if_template():
|
|||||||
assert at > html.index("</template>"), "l'iframe doit être hors du x-if"
|
assert at > html.index("</template>"), "l'iframe doit être hors du x-if"
|
||||||
|
|
||||||
|
|
||||||
def test_library_peek_loads_the_full_screen_editor():
|
def test_library_peek_loads_the_embed_editor_only():
|
||||||
"""Le peek charge `/pages/{id}` SANS `?embed=1` : exactement l'éditeur
|
"""Le peek charge `/pages/{id}?embed=1` : le document en mode édition
|
||||||
de la page entière (le mode embed masquait sidebar, barre et actions)."""
|
SEUL — le cadre du panneau suffit, ni sidebar ni barre de navigation."""
|
||||||
body = _js_func(_library_js(), "openPeek(item) {")
|
body = _js_func(_library_js(), "openPeek(item) {")
|
||||||
|
|
||||||
assert "frame.src = '/pages/' + item.id" in body
|
assert "frame.src = '/pages/' + item.id + '?embed=1'" in body
|
||||||
assert "embed=1" not in _strip_js_comments(body)
|
|
||||||
|
|
||||||
|
def test_library_peek_body_has_no_inset_padding():
|
||||||
|
"""Le corps du peek de Library est nu (padding 0, overflow hidden) :
|
||||||
|
l'iframe doit remplir le panneau comme dans local-workspace, sans double
|
||||||
|
scrollbar ni bande grise autour du document."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
html = Path("app/templates/library.html").read_text(encoding="utf-8")
|
||||||
|
rule = html.split(".peek-body{")[1].split("}")[0]
|
||||||
|
assert "padding:0" in rule
|
||||||
|
assert "overflow-y:auto" not in rule
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_side_peek_loads_the_embed_editor_only():
|
||||||
|
"""Library, My Tasks, bases et workspace local : le peek ne montre QUE le
|
||||||
|
document éditable (`?embed=1`), jamais l'habillage pleine page."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
for name in ("library.js", "my_tasks.js", "database_table.js",
|
||||||
|
"local_workspace.js"):
|
||||||
|
src = _strip_js_comments((Path("static/js") / name).read_text(encoding="utf-8"))
|
||||||
|
assert "?embed=1" in src, name
|
||||||
|
|
||||||
|
|
||||||
def test_library_peek_reloads_when_switching_documents():
|
def test_library_peek_reloads_when_switching_documents():
|
||||||
@@ -1419,16 +1441,6 @@ def test_close_peek_unloads_the_editor_iframe():
|
|||||||
assert "frame.onload = null" in body
|
assert "frame.onload = null" in body
|
||||||
|
|
||||||
|
|
||||||
def test_every_side_peek_loads_the_plain_page_url():
|
|
||||||
"""Library, My Tasks, bases et workspace local : même éditeur partout."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
for name in ("library.js", "my_tasks.js", "database_table.js",
|
|
||||||
"local_workspace.js"):
|
|
||||||
src = (Path("static/js") / name).read_text(encoding="utf-8")
|
|
||||||
assert "?embed=1" not in _strip_js_comments(src), name
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_offers_five_views():
|
def test_calendar_offers_five_views():
|
||||||
"""Mois / Année / Semaine / Jour / Planning."""
|
"""Mois / Année / Semaine / Jour / Planning."""
|
||||||
js = _my_tasks_js()
|
js = _my_tasks_js()
|
||||||
@@ -1451,6 +1463,56 @@ def test_calendar_events_have_a_side_peek_icon():
|
|||||||
assert "data-open-title=" in body
|
assert "data-open-title=" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_buttons_resolve_with_closest_not_target():
|
||||||
|
"""Le gestionnaire de clic lit `data-open-row` via `closest` : les boutons
|
||||||
|
du calendrier et du Kanban ne contiennent que leur SVG — un clic sur
|
||||||
|
l'icone (e.target = <svg>, sans dataset) doit ouvrir le peek aussi."""
|
||||||
|
js = _my_tasks_js()
|
||||||
|
i = js.index("app.addEventListener('click'")
|
||||||
|
block = js[i:i + 900]
|
||||||
|
assert "closest('[data-open-row]')" in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_kanban_cards_have_the_open_button():
|
||||||
|
"""Les tuiles du Kanban portent le même bouton « Open » que les lignes
|
||||||
|
du tableau (parité avec la page d'origine : même peek latéral)."""
|
||||||
|
body = _js_func(_my_tasks_js(), "function viewBoard(")
|
||||||
|
|
||||||
|
assert "openButton(t)" in body
|
||||||
|
# Le bouton est révélé au survol de la tuile (overlay, la carte est une
|
||||||
|
# colonne flex — pas de place en flux).
|
||||||
|
css = _my_tasks_css()
|
||||||
|
assert ".mt-card:hover .mt-hover-only" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_page_collection_renders_the_table_when_embedded():
|
||||||
|
"""`/pages/{id}?embed=1` sur une page de base garde le TABLEAU (template
|
||||||
|
collection) — le peek d'une base ouvre le tableau sans sidebar ni barre,
|
||||||
|
pas l'éditeur de blocs vide."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src = Path("app/routers/dashboard/pages_html.py").read_text(encoding="utf-8")
|
||||||
|
i = src.index('content_format") == "collection"')
|
||||||
|
branch = src[i:i + 220]
|
||||||
|
assert "not embed" not in branch.split("template")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_editor_can_render_inline_databases():
|
||||||
|
"""Le peek (`page_editor_embed.html`) doit charger database_table.js :
|
||||||
|
sans lui, une base embarquée restait bloquée à « Loading database… » et
|
||||||
|
l'icône Open de fin de ligne ne faisait rien. Le panneau #db-side-peek
|
||||||
|
est créé à la demande par database_table.js (markup unique)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
html = Path("app/templates/page_editor_embed.html").read_text(encoding="utf-8")
|
||||||
|
assert "_database_table_scripts.html" in html
|
||||||
|
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||||||
|
assert "ensureDbPeek" in js
|
||||||
|
# Pas de second markup statique du panneau (source unique).
|
||||||
|
tbl = Path("app/templates/_database_table.html").read_text(encoding="utf-8")
|
||||||
|
assert 'id="db-side-peek"' not in tbl
|
||||||
|
|
||||||
|
|
||||||
def test_open_button_carries_its_title_as_attribute():
|
def test_open_button_carries_its_title_as_attribute():
|
||||||
"""Le titre est lu depuis `dataset.openTitle` : disponible aussi dans le
|
"""Le titre est lu depuis `dataset.openTitle` : disponible aussi dans le
|
||||||
calendrier et la vue Jour, où il n'y a pas de ligne `.mt-row`."""
|
calendrier et la vue Jour, où il n'y a pas de ligne `.mt-row`."""
|
||||||
@@ -1520,4 +1582,6 @@ def test_db_side_peek_reports_resolution_failure_in_the_panel():
|
|||||||
|
|
||||||
assert "srcdoc" in block, "erreur rendue dans le panneau"
|
assert "srcdoc" in block, "erreur rendue dans le panneau"
|
||||||
assert "Impossible d’ouvrir le contenu" in block
|
assert "Impossible d’ouvrir le contenu" in block
|
||||||
assert block.index("srcdoc") < block.index("panel.style.display = 'flex'")
|
# Le panneau (révélé par `classList.add('open')` depuis la réécriture
|
||||||
|
# vanilla JS de v7.49.0) doit s'afficher APRÈS l'injection du message.
|
||||||
|
assert block.index("srcdoc") < block.index("classList.add('open')")
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""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 ; les sections pas encore livrées sont
|
||||||
|
désactivées (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 root.count("disabled:true") == 5 # tout sauf « fichiers/répertoires »
|
||||||
|
assert "action:'files'" in root
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user