feat: menu + de l'assistant en hub de contexte — phase 1/8 (v7.51.0)
- 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.
This commit is contained in:
@@ -1,5 +1,35 @@
|
|||||||
# 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)
|
## v7.50.2 (2026-10-06) — Peek de Library : nu comme local-workspace (plus de bande ni double scrollbar)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+23
-14
@@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (planifié 2026-10-06)
|
## 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)
|
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
|
||||||
> un menu à sections, tel que demandé :
|
> un menu à sections, tel que demandé :
|
||||||
@@ -301,7 +301,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
> **Doc de design** : [`docs/V74_Agent_Plus_Menu.md`](docs/V74_Agent_Plus_Menu.md)
|
> **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.
|
> — 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** (rien n'est livré, tout est `- [ ]`) :
|
**É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
|
- Le **+** = `toggleAddPicker()` (`static/js/agent_panel_2.js:1074`) → menu **plat** de
|
||||||
mentions (`GET /api/agent/mentions`, `app/routers/agent.py:596`) : documents,
|
mentions (`GET /api/agent/mentions`, `app/routers/agent.py:596`) : documents,
|
||||||
@@ -318,18 +318,27 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
**Ordre** : phases 1 → 8, chacune indépendante et livrable (version + tests verts +
|
**Ordre** : phases 1 → 8, chacune indépendante et livrable (version + tests verts +
|
||||||
CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
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
|
### Phase 1 — v7.51.0 — Menu hub + fichiers/répertoires · effort S–M ✅ (livrée 2026-10-06)
|
||||||
|
|
||||||
- [ ] **Structure du menu** — sections + sous-menus, navigation clavier (↑/↓/Entrée/Échap),
|
- [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
|
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)
|
s'affichent « bientôt » et désactivées (le menu ne promet rien que le code ne fait)
|
||||||
- [ ] **Fichiers & répertoires** — picker actuel (recherche) **+ mode « Parcourir… »** :
|
→ `FD_PLUS_MENU` / `FD_PLUS_FILES` (`static/js/agent_panel_2.js`), markup
|
||||||
arborescence via `GET /api/local-workspace/tree?folder=` + enfants de page
|
`.fd-ap-plus-menu` (`agent_panel.html`), Échap remonte d'un niveau, la frappe
|
||||||
(`parent_id`) ; un répertoire s'épingle comme un fichier
|
referme le menu
|
||||||
- [ ] **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context`
|
- [x] **Fichiers & répertoires** — picker actuel (recherche) **+ mode « Parcourir… »** :
|
||||||
(contenu des pages du dossier, borné en tokens)
|
arborescence **un niveau par appel via `GET /api/nav/menu?parent_id=`**
|
||||||
- [ ] **Tests** — parcours, résolution du jeton dossier, rendu/états du menu
|
(contrat : dossier = `icon === 'folder'` — plus simple que le tree récursif,
|
||||||
- [ ] **Livraison** — `ruff` + suite verte, bump `VERSION` + `app/main.py`, CHANGELOG
|
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
|
### Phase 2 — v7.52.0 — Compétences : « Gérer » + « Parcourir » · effort M
|
||||||
|
|
||||||
@@ -407,9 +416,9 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Plan produit le 2026-10-06 à partir du code réel (aucune phase commencée) — à valider
|
*Plan produit le 2026-10-06 à partir du code réel — **phase 1 livrée le 2026-10-06
|
||||||
phase par phase avant « go ». Chaque phase se clôt par tests verts, bump de version,
|
(v7.51.0)**, phases 2-8 à valider une par une avant « go ». Chaque phase se clôt par
|
||||||
CHANGELOG et ROADMAP à jour.*
|
tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.50.2",
|
version="7.51.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# V74 — Menu + de l'assistant : hub de contexte (design)
|
# V74 — Menu + de l'assistant : hub de contexte (design)
|
||||||
|
|
||||||
> **Statut** : design — aucune phase n'est commencée.
|
> **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 ».
|
> **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).
|
> **Date** : 2026-10-06 · Version cible de départ : **v7.51.0** (phase 1).
|
||||||
|
|
||||||
|
|||||||
+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();
|
||||||
|
|||||||
@@ -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