Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a68e8848ea | ||
|
|
18a2d45d46 | ||
|
|
f271ac9b7a | ||
|
|
f006880394 |
+103
@@ -1,5 +1,108 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.53.0 (2026-10-06) — Menu + : Design System – Canevas (phase 3/8)
|
||||
|
||||
### Added
|
||||
|
||||
- **Section « Design System – Canevas »** — liste des canevas
|
||||
(`GET /board/api/page-templates` : intégrés + personnels), puis une page de
|
||||
détail avec 3 actions :
|
||||
- **Créer une page à partir du canevas** (`POST …/0/use` avec `{key}` *dans le
|
||||
body* pour un intégré, `POST …/{id}/use` pour un personnel) ;
|
||||
- **Insérer dans le document ouvert** — `GET …/blocks` (nouvelle route) →
|
||||
`ensureBlockIds()` (global côté éditeur) → concaténation dans `E.blocks` +
|
||||
`autoSave/render`, désactivé si aucun document éditable n'est ouvert ;
|
||||
- **Enregistrer le document ouvert comme canevas**
|
||||
(`POST /board/api/page-templates` avec `page_id`).
|
||||
- **`GET /board/api/page-templates/{template_id}/blocks`** — blocs d'un canevas
|
||||
( intégré via `template_id=0&key=`, sinon id) ; 404 clé/id inconnu,
|
||||
500 si le JSON est corrompu. **OpenAPI : 516 chemins**, `info.version` 7.53.0.
|
||||
- **Canevas « Design System »** intégré (`design_system` dans
|
||||
`app/services/block_templates.py`) : callout tokens, TOC, 3 toggles
|
||||
composants, grille & espacement, checklist revue UI, citation.
|
||||
- **Navigation** : en-tête ← gère le niveau canevas → liste.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Prefixe des canevas** : le front du panneau utilisait `/api/page-templates`
|
||||
(404 silencieux) — la route réelle est `/board/api/page-templates` (router
|
||||
`prefix="/board"`), comme `base.html` le fait déjà.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v753_canvases.py` — **5 tests** : canevas `design_system` listé,
|
||||
route blocks intégré (sans `id`, 404 clé inconnue, `empty` par défaut),
|
||||
route blocks personnel + 404, création de page depuis le canevas (blocs lus
|
||||
en base, ids assignés), câblage des sections (3 « bientôt » restantes) +
|
||||
garde-fou « `key` dans le body ». `test_v751` adapté (3 sections désactivées).
|
||||
- **Suite complète : 1285 passed / 0 failed** (`-n auto`), `ruff` 0,
|
||||
`eslint` 0 problème.
|
||||
|
||||
|
||||
## v7.52.0 (2026-10-06) — Menu + : Compétences-skills « Gérer » + « Parcourir » (phase 2/8)
|
||||
|
||||
### Added
|
||||
|
||||
- **Sous-menu « Compétences-skills »** — 11 skills builtin + les skills
|
||||
enregistrés, avec **libellés affichés** « Deep research » (`/research`) et
|
||||
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS` (slugs intacts) ;
|
||||
le clic épingle le skill au contexte, même chemin que la palette `/`.
|
||||
- **« Gérer les compétences »** — section du menu : liste des skills,
|
||||
formulaire intégré (nom, description, prompt) pour créer/éditer, suppression
|
||||
avec nettoyage du chip épinglé, **export JSON** téléchargé et **import** en
|
||||
fichier (`overwrite:true` → jamais de doublon).
|
||||
- **« Parcourir les compétences »** — section galerie filtrable (nom /
|
||||
description / slug), installation en 1 clic (`installGallerySkill`).
|
||||
- **`PATCH /api/agent/skills/{skill_id}`** — édition partielle, whitelist de
|
||||
colonnes figée côté serveur, réponses 400 (champ vide / aucun champ),
|
||||
401 (sans session), 404, 409 (nom déjà pris). **OpenAPI régénéré** :
|
||||
`docs/openapi-v2.json` → 515 chemins, `info.version` 7.52.0.
|
||||
- **En-tête ← + titre de section** dans le menu (indispensable à 3 niveaux :
|
||||
racine → compétences → gérer/parcourir/formulaire).
|
||||
- **Fix** : épingler un skill enregistré **n'efface plus** le message déjà tapé
|
||||
(`_pinDbSkill` ne vide le composer que s'il est vide).
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v752_skills_menu.py` — **7 tests** : PATCH complet + partiel,
|
||||
validations 400/404, 401 sans session (CSRF valide pour isoler la route),
|
||||
import sans doublon (409 puis `overwrite` → 1 seule ligne), aller-retour
|
||||
export → import, câblage des sections + alias, markup rendu dans la page.
|
||||
- `tests/test_v751_plus_menu.py` adapté : 4 sections « bientôt » au lieu de 5.
|
||||
- **Suite complète : 1280 passed / 0 failed** (`-n auto`), `ruff check app tests`
|
||||
propre, `eslint static/js/agent_panel_2.js` **0 problème**.
|
||||
|
||||
|
||||
## v7.51.0 (2026-10-06) — Menu + de l'assistant : hub de contexte (phase 1/8)
|
||||
|
||||
### Added
|
||||
|
||||
- **Menu à sections sur le bouton +** (`agent_panel.html` + `agent_panel_2.js`) :
|
||||
le + ouvre *Ajouter des fichiers ou répertoires*, *Compétences-skills*,
|
||||
*Connecteurs*, *Design System – Canevas*, *Add plugins*, *Mémoire (on/off)*.
|
||||
Sections pas encore livrées affichées « bientôt (phase N) » et **désactivées**
|
||||
(décision D3 : le menu ne promet rien que le code ne fait). Navigation clavier
|
||||
↑/↓/Entrée/Échap, focus visible, Échap revient d'un niveau.
|
||||
- **Parcours fichiers & répertoires** — « Parcourir… » affiche un niveau à la fois
|
||||
via `/api/nav/menu` (contrat : dossier = `icon === 'folder'`) avec fil d'Ariane
|
||||
cliquable ; un document s'épingle (`document:<id>`), un dossier s'épingle depuis
|
||||
la ligne « 📌 Épingler le dossier » (`folder:<id>`).
|
||||
- **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` : titre du
|
||||
dossier + documents directs, budget ~12k caractères (marqueur « contenu du
|
||||
dossier tronqué »). Enfants directs seulement — récursion à ajouter si les
|
||||
arbres profonds deviennent réels.
|
||||
- **« Rechercher… »** conserve l'ancien sélecteur de mentions (recherche par nom,
|
||||
`@` inline) à l'identique : zéro régression du chemin existant.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v751_plus_menu.py` — **7 tests** : résolution du dossier, budget
|
||||
borné, id inconnu, contrat `/api/nav/menu`, rendu du menu dans la page,
|
||||
6 sections dont 5 désactivées, jeton `folder:` côté front et côté backend.
|
||||
- **Suite complète : 1273 passed / 0 failed** (`-n auto`), `ruff check app tests`
|
||||
propre, `eslint static/js` **0 erreur**.
|
||||
|
||||
|
||||
## v7.50.2 (2026-10-06) — Peek de Library : nu comme local-workspace (plus de bande ni double scrollbar)
|
||||
|
||||
### Fixed
|
||||
|
||||
+165
@@ -284,6 +284,171 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
|
||||
---
|
||||
|
||||
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-3 ✅ 2026-10-06 · phases 4-8 planifiées)
|
||||
|
||||
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
|
||||
> un menu à sections, tel que demandé :
|
||||
>
|
||||
> ```
|
||||
> + ├─ Ajouter des fichiers ou répertoires … → picker actuel + parcours de l'arborescence
|
||||
> ├─ Compétences-skills … … … … … … … … → Deep research, Skill-creator, … + Gérer + Parcourir
|
||||
> ├─ Connecteurs … … … … … … … … … … … → Parcourir + Ajouter un connecteur personnalisé
|
||||
> ├─ Design System – Canevas … … … … … → galerie de canevas (templates de page)
|
||||
> ├─ Add plugins … … … … … … … … … … … → catalogue de modules on/off
|
||||
> └─ Mémoire (on/off) … … … … … … … … → mémoire persistante de l'agent
|
||||
> ```
|
||||
>
|
||||
> **Doc de design** : [`docs/V74_Agent_Plus_Menu.md`](docs/V74_Agent_Plus_Menu.md)
|
||||
> — maquette, découpage front/back, modèle de données, endpoints, décisions, pièges.
|
||||
|
||||
**État des lieux lu dans le code le 2026-10-06** (constat **avant** la phase 1) :
|
||||
|
||||
- Le **+** = `toggleAddPicker()` (`static/js/agent_panel_2.js:1074`) → menu **plat** de
|
||||
mentions (`GET /api/agent/mentions`, `app/routers/agent.py:596`) : documents,
|
||||
collections, pages. **Pas de dossiers, pas de sous-menu, pas de section.**
|
||||
- **Compétences** : 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) +
|
||||
table `agent_skills` (CRUD `/api/agent/skills`) + galerie de 17 presets — mais
|
||||
**aucune UI « Gérer » ni « Parcourir »** : tout passe par la palette `/`.
|
||||
- **Canevas** : `GET /api/page-templates` (built-ins `app/services/block_templates.py` +
|
||||
personnels) et `POST /api/page-templates/{id}/use` existent déjà, **jamais appelés
|
||||
depuis le panneau agent**.
|
||||
- **Connecteurs / Plugins / Mémoire** : **inexistants** — 0 hit `connector|plugin|memory`
|
||||
dans `app/` hors « in-memory ».
|
||||
|
||||
**Ordre** : phases 1 → 8, chacune indépendante et livrable (version + tests verts +
|
||||
CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
||||
|
||||
### Phase 1 — v7.51.0 — Menu hub + fichiers/répertoires · effort S–M ✅ (livrée 2026-10-06)
|
||||
|
||||
- [x] **Structure du menu** — sections + sous-menus, navigation clavier (↑/↓/Entrée/Échap),
|
||||
styles réutilisés depuis `.fd-ap-mention-menu` ; les sections pas encore livrées
|
||||
s'affichent « bientôt » et désactivées (le menu ne promet rien que le code ne fait)
|
||||
→ `FD_PLUS_MENU` / `FD_PLUS_FILES` (`static/js/agent_panel_2.js`), markup
|
||||
`.fd-ap-plus-menu` (`agent_panel.html`), Échap remonte d'un niveau, la frappe
|
||||
referme le menu
|
||||
- [x] **Fichiers & répertoires** — picker actuel (recherche) **+ mode « Parcourir… »** :
|
||||
arborescence **un niveau par appel via `GET /api/nav/menu?parent_id=`**
|
||||
(contrat : dossier = `icon === 'folder'` — plus simple que le tree récursif,
|
||||
déjà testé), fil d'Ariane cliquable ; un répertoire s'épingle comme un fichier
|
||||
(ligne « 📌 Épingler le dossier »)
|
||||
- [x] **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` →
|
||||
`_single_folder()` : titre du dossier + documents directs, budget ~12k caractères
|
||||
(marqueur « tronqué ») ; enfants directs seulement (récursion si besoin réel)
|
||||
- [x] **Tests** — `tests/test_v751_plus_menu.py` : **7 tests** (résolution, budget,
|
||||
id inconnu, contrat `/api/nav/menu`, rendu du menu, 6 sections / 5 désactivées
|
||||
à la livraison de la phase 1, jeton `folder:` front+back) ; **suite complète
|
||||
1273 verts** à la livraison, `ruff` 0, `eslint` 0 erreur
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.51.0**, CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 2 — v7.52.0 — Compétences : « Gérer » + « Parcourir » · effort M ✅ (livrée 2026-10-06)
|
||||
|
||||
- [x] **Sous-menu Compétences** — 11 builtin + skills enregistrés listés, le clic
|
||||
épingle le skill au contexte (même chemin que la palette `/`, comportement
|
||||
conservé)
|
||||
- [x] **Libellés affichés** — alias « Deep research » (`/research`) et
|
||||
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS`, slugs intacts
|
||||
- [x] **« Gérer les compétences »** — **section du menu + formulaire intégré**
|
||||
(nom, description, prompt) plutôt que modale : CRUD `POST`/`PATCH`/`DELETE`
|
||||
sur `/api/agent/skills` — **`PATCH /api/agent/skills/{id}` créé** (champs
|
||||
présents seuls, whitelist de colonnes, 400/401/404/409) + **export JSON**
|
||||
(`/skills/{id}/export` → téléchargement) et **import fichier**
|
||||
(`/skills/import`, `overwrite:true` → pas de doublon) ; suppression = nettoyage
|
||||
du chip épinglé
|
||||
- [x] **« Parcourir les compétences »** — section galerie sur
|
||||
`GET /api/agent/skills/gallery`, **filtre** (nom/description/slug),
|
||||
installation en 1 clic (réutilise `installGallerySkill`)
|
||||
- [x] **En-tête ← + titre** dans le menu (navigation à 3 niveaux)
|
||||
- [x] **Fix** — épingler un skill enregistré n'efface plus le message tapé
|
||||
- [x] **Tests** — `tests/test_v752_skills_menu.py` : **7 tests** (PATCH complet +
|
||||
partiel, 400/404, 401 sans session, import sans doublon, export→import,
|
||||
câblage sections/alias, markup) ; `test_v751` adapté (4 sections « bientôt ») ;
|
||||
**suite complète 1280 verts**, `ruff` 0, `eslint` 0 problème
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.52.0**, OpenAPI régénéré
|
||||
(515 chemins), CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 3 — v7.53.0 — Design System – Canevas · effort M ✅ (livrée 2026-10-06)
|
||||
|
||||
- [x] **Sous-menu Canevas** — `GET /board/api/page-templates` (built-ins **+
|
||||
personnels**) ; **piège confirmé** : le router est sous `prefix="/board"` →
|
||||
l'URL réelle est `/board/api/page-templates`, pas `/api/page-templates`
|
||||
(404 silencieux corrigé dans le front)
|
||||
- [x] **Deux actions (3 en réalité)** — page de détail par canevas :
|
||||
« Créer une page à partir du canevas » (`POST …/0/use` avec `{key}` **dans le
|
||||
body** — la clé en query est ignorée par la route), « Insérer dans le document
|
||||
ouvert » (`GET …/blocks` **nouvelle route** → `ensureBlockIds()` global →
|
||||
concat dans `E.blocks` + `autoSave/render`, désactivé sans document ouvert)
|
||||
et « Enregistrer le document ouvert comme canevas » (`POST` + `page_id`)
|
||||
- [x] **Canevas « design system »** — preset `design_system` dans
|
||||
`block_templates.py` (callout tokens, TOC, toggles composants, grille,
|
||||
checklist revue UI) ; `design-tokens.css` référencé dans le callout
|
||||
- [x] **Sauver le doc ouvert comme canevas** — `POST /board/api/page-templates`
|
||||
(existante) branchée depuis le menu, nom via `prompt()` natif
|
||||
- [x] **Tests** — `tests/test_v753_canvases.py` : **5 tests** (preset listé,
|
||||
route blocks intégré/personnel + 404, création de page vérifiée en base avec
|
||||
ids, câblage 3 « bientôt » + garde-fou `key`-dans-le-body) ; `test_v751`
|
||||
adapté ; **suite complète 1285 verts**, `ruff` 0, `eslint` 0 problème
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.53.0**, OpenAPI régénéré
|
||||
(516 chemins), CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M
|
||||
|
||||
- [ ] **Table `agent_memory`** (`workspace_id`, `kind` summary|fact, `content`,
|
||||
`updated_at`) + colonne `conversations.memory_enabled`
|
||||
- [ ] **Service `app/services/agent_memory.py`** — extraction en fin de run (résumé +
|
||||
faits saillants ; LLM sans outils, repli déterministe hors-ligne)
|
||||
- [ ] **Injection dans `ContextBuilder` si le toggle est ON** ; OFF = contexte strict
|
||||
de la conversation courante
|
||||
- [ ] **Toggle « Mémoire »** dans le menu + : état visible, persisté par conversation,
|
||||
défaut `AGENT_MEMORY_DEFAULT`
|
||||
- [ ] **Tests** — injection ON / absence OFF / persistance / budget de tokens borné
|
||||
|
||||
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L
|
||||
|
||||
- [ ] **Table `agent_connectors`** (`kind`, `name`, `config`, credentials chiffrés,
|
||||
`enabled`, `status`)
|
||||
- [ ] **`app/services/connectors/`** — interface commune `probe() / list_sources() / fetch()`
|
||||
+ 3 adapters **natifs déjà présents** : Gitea, GitHub, Web (`web_search` / `fetch_url`, v7.46)
|
||||
- [ ] **Menu « Connecteurs »** — « Parcourir les connecteurs » (catalogue + statut
|
||||
connecté/déconnecté) et « Ajouter un connecteur personnalisé » (URL + clé + type
|
||||
HTTP / MCP)
|
||||
- [ ] **Tools agent** — un tool par connecteur exposé au LLM via `tool_registry`
|
||||
- [ ] **Sécurité** — garde SSRF `_is_public_host` sur toute URL, credentials chiffrés,
|
||||
session requise, CSRF sur les écritures
|
||||
- [ ] **Tests** — CRUD, statut, vecteurs SSRF, schéma des tools
|
||||
|
||||
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L
|
||||
|
||||
- [ ] **OAuth2 PKCE Google** — Drive, Gmail, Calendar : connexion, refresh, déconnexion,
|
||||
lecture limitée aux sources choisies
|
||||
- [ ] **OAuth2 Microsoft (Graph)** — Outlook / OneDrive / SharePoint
|
||||
- [ ] **Écran connecteur** — état, scopes, dernière synchro, bouton déconnecter
|
||||
- [ ] **Tests** — callbacks, refresh, échec, **aucun appel réseau réel** (transport injecté)
|
||||
|
||||
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L
|
||||
|
||||
- [ ] **Discord** (bot token : lecture canaux/DMs), **Telegram** (Bot API),
|
||||
**Teams** (via Graph de la phase 6)
|
||||
- [ ] **MCP complet** — handshake `initialize`, `tools/list`, `tools/call`
|
||||
(streamable HTTP) → outils **dynamiques** ajoutés au registre à la connexion
|
||||
- [ ] **Tests** — schémas MCP, mapping d'outils, échecs d'auth
|
||||
|
||||
### Phase 8 — v7.58.0 — Add plugins · effort M
|
||||
|
||||
- [ ] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — état persisté
|
||||
- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin
|
||||
désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de
|
||||
l'agent, web clipper, automations)
|
||||
- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu)
|
||||
|
||||
---
|
||||
|
||||
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 3 livrées le
|
||||
2026-10-06 (v7.51.0, v7.52.0, v7.53.0)**, phases 4-8 à valider une par une avant
|
||||
« go ». Chaque phase se clôt par tests verts, bump de version, CHANGELOG et
|
||||
ROADMAP à jour.*
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Prochaines versions
|
||||
|
||||
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.50.2",
|
||||
version="7.53.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -498,6 +498,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/skills/{skill_id}")
|
||||
def update_skill(request: Request, skill_id: int, body: dict = Body(default={})):
|
||||
"""Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).
|
||||
|
||||
Seuls les champs présents dans ``body`` sont mis à jour ; la liste des
|
||||
colonnes est figée ici (jamais interpolée depuis la requête).
|
||||
"""
|
||||
_current_user_id(request)
|
||||
fields: dict = {}
|
||||
if "name" in body:
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name est requis")
|
||||
fields["name"] = name
|
||||
if "description" in body:
|
||||
fields["description"] = str(body.get("description") or "")
|
||||
if "prompt_template" in body:
|
||||
fields["prompt_template"] = str(body.get("prompt_template") or "")
|
||||
if "allowed_tools" in body:
|
||||
fields["allowed_tools_json"] = json.dumps(body.get("allowed_tools") or [])
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Aucun champ à mettre à jour")
|
||||
cols = ", ".join(f"{k}=?" for k in fields)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agent_skills WHERE id=?", (skill_id,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
try:
|
||||
conn.execute(
|
||||
f"UPDATE agent_skills SET {cols} WHERE id=?",
|
||||
(*fields.values(), skill_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # noqa: BLE001 — contrainte UNIQUE(name)
|
||||
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
|
||||
return {"id": skill_id, "status": "updated", "fields": sorted(fields)}
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
def apply_skill(request: Request, skill_id: int):
|
||||
"""Create a conversation pre-loaded with a skill, ready to run."""
|
||||
|
||||
@@ -226,4 +226,38 @@ def use_page_template(request: Request, template_id: int, body: dict = Body(defa
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
@router.get("/api/page-templates/{template_id}/blocks")
|
||||
def page_template_blocks(request: Request, template_id: int, key: str = ""):
|
||||
"""v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.
|
||||
|
||||
Builtin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).
|
||||
Canevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le
|
||||
front appelle ``ensureBlockIds()`` (déjà global côté éditeur).
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if template_id == 0:
|
||||
from app.services.block_templates import blocks_json_for
|
||||
raw = blocks_json_for(key or "empty")
|
||||
if raw is None:
|
||||
raise HTTPException(404, "Unknown built-in template")
|
||||
else:
|
||||
uid = (user or {}).get("id")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT blocks_json FROM page_global_templates "
|
||||
"WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||
(template_id, uid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
raw = row["blocks_json"]
|
||||
try:
|
||||
blocks = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
raise HTTPException(500, "Template content corrupted") from exc
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(500, "Template content corrupted")
|
||||
return {"blocks": blocks}
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
|
||||
@@ -86,6 +86,34 @@ BUILTIN_TEMPLATES: dict[str, dict] = {
|
||||
_b("bulleted_list"),
|
||||
],
|
||||
},
|
||||
"design_system": {
|
||||
"name": "Design System",
|
||||
"icon": "🎨",
|
||||
"description": "Tokens, composants et grille — canevas de référence UI.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Design System"),
|
||||
_b("callout",
|
||||
"Tokens : couleur, espacement, typographie, rayon, ombres "
|
||||
"(static/css/design-tokens.css).",
|
||||
icon="🎨"),
|
||||
_b("table_of_contents"),
|
||||
_b("heading_2", "Composants"),
|
||||
_b("toggle", "Boutons & actions", expanded=False,
|
||||
children=[_b("paragraph", "Primaire · secondaire · danger — états idle, hover, focus, disabled.")]),
|
||||
_b("toggle", "Cartes & panneaux", expanded=False,
|
||||
children=[_b("paragraph", "Élévation, rayon, bordure, padding, zones de clic.")]),
|
||||
_b("toggle", "Formulaires", expanded=False,
|
||||
children=[_b("paragraph", "Champs, labels, hints, erreurs, focus visible.")]),
|
||||
_b("heading_2", "Grille & espacement"),
|
||||
_b("bulleted_list", "Base 4 px — pas d'espacement hors échelle."),
|
||||
_b("bulleted_list", "Colonnes : 12 / 8 / 4 selon le point de rupture."),
|
||||
_b("heading_2", "Revue UI"),
|
||||
_b("to_do", "Contraste AA vérifié", checked=False),
|
||||
_b("to_do", "États hover / focus / disabled", checked=False),
|
||||
_b("to_do", "Responsive mobile", checked=False),
|
||||
_b("quote", "Une décision de design vaut mieux qu'une justification après coup."),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ class ContextBuilder:
|
||||
return "\n".join(lines)
|
||||
|
||||
def _mentions_context(self, mentions: list[str]) -> str:
|
||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
||||
"""Resolve @document:x / @collection:x / @page:y / @folder:d / @repo:o/r mentions.
|
||||
|
||||
Mentions bring the *actual content* of the referenced object into the
|
||||
context so the LLM can summarise / rewrite / analyse it directly without
|
||||
@@ -135,6 +135,8 @@ class ContextBuilder:
|
||||
elif m.startswith("page:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_page(pid))
|
||||
elif m.startswith("folder:"):
|
||||
lines.append(self._single_folder(m.split(":", 1)[1]))
|
||||
elif m.startswith("repo:"):
|
||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||
elif m == "ws":
|
||||
@@ -209,6 +211,37 @@ class ContextBuilder:
|
||||
return f"{head}:\n{body[:9000]}"
|
||||
return head
|
||||
|
||||
def _single_folder(self, fid: str) -> str:
|
||||
"""Folder (répertoire) mention : titre du dossier + ses documents directs.
|
||||
|
||||
ponytail : enfants directs seulement, budget ~12k caractères —
|
||||
ajouter une profondeur récursive si les arbres profonds deviennent réels.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(fid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return f"- dossier #{fid}: introuvable"
|
||||
kids = conn.execute(
|
||||
"SELECT id FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(fid,),
|
||||
).fetchall()
|
||||
title = row["title"] or "Sans titre"
|
||||
head = f"- dossier #{row['id']} **{title}** ({len(kids)} élément(s))"
|
||||
out = [head]
|
||||
used = len(head)
|
||||
for k in kids:
|
||||
part = self._single_document(str(k["id"]))
|
||||
if used + len(part) + 1 > 12000:
|
||||
out.append("- … (contenu du dossier tronqué)")
|
||||
break
|
||||
used += len(part) + 1
|
||||
out.append(part)
|
||||
return "\n".join(out)
|
||||
|
||||
def _single_collection(self, cid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
|
||||
@@ -180,6 +180,26 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
|
||||
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
|
||||
|
||||
/* Menu + (sections) — réutilise le skin du menu de mentions */
|
||||
.fd-ap-plus-menu{max-height:min(340px,60vh)}
|
||||
.fd-ap-plus-menu .fd-mention-item.disabled{opacity:.45;cursor:default;background:none}
|
||||
.fd-ap-plus-menu .fd-mention-item.disabled:hover{background:none}
|
||||
.fd-plus-head{display:flex;align-items:center;gap:8px;padding:7px 11px 6px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
|
||||
.fd-plus-back{background:none;border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);border-radius:7px;width:26px;height:22px;cursor:pointer;line-height:1;flex-shrink:0}
|
||||
.fd-plus-back:hover{background:var(--bg-hover,#2b2b2b)}
|
||||
.fd-plus-title{font-size:11px;font-weight:600;color:var(--text-secondary,#ddd);text-transform:uppercase;letter-spacing:.4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.fd-plus-filter{padding:7px 11px 2px}
|
||||
.fd-plus-filter input{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:6px 8px;outline:none;box-sizing:border-box}
|
||||
.fd-plus-form{padding:4px 11px 12px}
|
||||
.fd-plus-form label{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:9px 0 3px}
|
||||
.fd-plus-form input,.fd-plus-form textarea{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:7px 8px;outline:none;font-family:inherit;box-sizing:border-box}
|
||||
.fd-plus-form textarea{min-height:110px;resize:vertical;line-height:1.5}
|
||||
.fd-plus-file{display:none}
|
||||
.fd-plus-chev{margin-left:auto;color:var(--text-dim,#999);opacity:.75;font-size:15px;flex-shrink:0}
|
||||
.fd-plus-crumb{display:flex;gap:5px;flex-wrap:wrap;padding:7px 11px 3px;font-size:11px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
|
||||
.fd-plus-crumb-it{color:var(--accent,#2383E2);cursor:pointer;white-space:nowrap}
|
||||
.fd-plus-crumb-it:hover{text-decoration:underline}
|
||||
|
||||
/* Toast */
|
||||
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
|
||||
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
|
||||
@@ -380,6 +400,52 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fd-ap-mention-menu fd-ap-plus-menu" :class="{show: plusOpen}" x-ref="plusMenu">
|
||||
<div class="fd-plus-head" x-show="plusSection!=='root'" x-cloak>
|
||||
<button class="fd-plus-back" type="button" @click="plusBack()" title="Retour" aria-label="Retour">←</button>
|
||||
<span class="fd-plus-title" x-text="plusTitle"></span>
|
||||
</div>
|
||||
<div class="fd-plus-filter" x-show="plusSection==='skills-gallery'" x-cloak>
|
||||
<input type="search" placeholder="Filtrer les compétences…" aria-label="Filtrer les compétences" x-model="galleryFilter">
|
||||
</div>
|
||||
<div class="fd-plus-crumb" x-show="plusSection==='browse'">
|
||||
<span class="fd-plus-crumb-it" @click="plusGoto(-1)">Racine</span>
|
||||
<template x-for="(c, ci) in plusCrumb" :key="c.id">
|
||||
<span class="fd-plus-crumb-it" @click="plusGoto(ci)" x-text="'› ' + c.name"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fd-mention-load" x-show="plusSection==='browse' && plusLoading" x-cloak>Chargement…</div>
|
||||
<div class="fd-mention-empty" x-show="plusSection==='browse' && !plusLoading && !plusItems.length" x-cloak>Dossier vide</div>
|
||||
<template x-for="(it, i) in plusItems" :key="it.key">
|
||||
<div>
|
||||
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
|
||||
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: i===plusFocus, disabled: !!it.disabled}"
|
||||
@click="plusAction(it)" @mouseenter="it.disabled ? null : plusFocus = i">
|
||||
<span class="fd-mention-ico" x-text="it.icon"></span>
|
||||
<div class="fd-mention-main">
|
||||
<div class="fd-mention-lbl" x-text="it.label"></div>
|
||||
<div class="fd-mention-sub" x-text="it.sub"></div>
|
||||
</div>
|
||||
<span class="fd-plus-chev" x-show="it.chev || it._folder">›</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fd-plus-form" x-show="plusSection==='skills-edit'" x-cloak>
|
||||
<label for="fd-plus-skill-name">Nom</label>
|
||||
<input id="fd-plus-skill-name" type="text" x-model="skillForm.name" placeholder="ex. synthese-reunion" @keydown.enter.prevent="saveSkill()">
|
||||
<label for="fd-plus-skill-desc">Description</label>
|
||||
<input id="fd-plus-skill-desc" type="text" x-model="skillForm.description" placeholder="Une phrase">
|
||||
<label for="fd-plus-skill-prompt">Prompt (consigne appliquée à la demande)</label>
|
||||
<textarea id="fd-plus-skill-prompt" x-model="skillForm.prompt_template" placeholder="Fais…"></textarea>
|
||||
<div class="fd-proposal-bar">
|
||||
<button class="fd-proposal-btn primary" type="button" @click="saveSkill()">✓ Enregistrer</button>
|
||||
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="deleteSkill()" x-cloak>✕ Supprimer</button>
|
||||
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="exportSkill()" x-cloak>⤓ Export</button>
|
||||
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" accept="application/json,.json" x-ref="skillImport" class="fd-plus-file" aria-label="Importer un skill JSON" @change="importSkillFile($event)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
|
||||
<div class="fd-chip-row" x-show="mentionChips.length">
|
||||
@@ -406,7 +472,7 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
|
||||
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
|
||||
<div class="fd-ap-toolbar">
|
||||
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
|
||||
<button class="fd-ap-add" type="button" title="Ajouter au contexte : fichiers, répertoires, compétences…" aria-label="Menu d'ajout au contexte" aria-haspopup="menu" :aria-expanded="plusOpen" @click="toggleAddPicker()">+</button>
|
||||
<span class="fd-ap-tip" x-text="composerHint"></span>
|
||||
<div class="fd-ap-tools">
|
||||
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# V74 — Menu + de l'assistant : hub de contexte (design)
|
||||
|
||||
> **Statut** : design — **phases 1 à 3 livrées (v7.51.0 → v7.53.0, 2026-10-06)** ;
|
||||
> phases 4-8 restent à livrer. Adaptations faites en cours de route : le parcours
|
||||
> utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat
|
||||
> dossier = `icon === 'folder'`), la gestion des skills est une **section du menu
|
||||
> avec formulaire intégré** (pas une modale — `PATCH /api/agent/skills/{id}`
|
||||
> ajouté), et les canevas vivent sous **`/board/api/page-templates`** (prefix
|
||||
> `/board`) avec la nouvelle route `GET …/{id}/blocks` pour l'insertion.
|
||||
> **Plan phasé** : `ROADMAP.md` → section « v7.51.0 → v7.58.0 — Menu + de l'assistant ».
|
||||
> **Date** : 2026-10-06 · Version cible de départ : **v7.51.0** (phase 1).
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Le bouton **+** du panneau agent (`app/templates/agent_panel.html:409`) ouvre
|
||||
aujourd'hui une liste plate de fichiers/pages/bases à épingler en contexte. On le
|
||||
transforme en **menu à sections à deux niveaux** :
|
||||
|
||||
```
|
||||
+ ┌──────────────────────────────────────────────────────┐
|
||||
│ 📎 Ajouter des fichiers ou répertoires › │ → picker actuel + « Parcourir… »
|
||||
│ ✨ Compétences-skills › │ → Deep research, Skill-creator, …
|
||||
│ │ ────────────────
|
||||
│ │ Gérer les compétences
|
||||
│ │ Parcourir les compétences
|
||||
│ 🔌 Connecteurs › │ → Parcourir les connecteurs
|
||||
│ │ Ajouter un connecteur personnalisé
|
||||
│ 🎨 Design System – Canevas › │ → liste des canevas
|
||||
│ 🧩 Add plugins │ → catalogue on/off (modale)
|
||||
│ 🧠 Mémoire [◉/○] │ → toggle persisté
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Règle d'or : **chaque entrée vit dans la même phase qui la rend réelle**. Tant qu'une
|
||||
section n'est pas livrée, elle s'affiche grisée « Bientôt (phase N) » — le menu ne
|
||||
promet rien que le code ne fait.
|
||||
|
||||
## 2. État des lieux (lu dans le code, 2026-10-06)
|
||||
|
||||
| Section | Existe | Manque |
|
||||
|---|---|---|
|
||||
| Fichiers / répertoires | `GET /api/agent/mentions` (`app/routers/agent.py:596`) → documents, collections, pages ; jetons `document:id`, `collection:id`, `page:id` résolus par `ContextBuilder._mentions_context` (`app/services/context_builder.py:120`) | Sous-menu, mode parcours d'arborescence, jeton `folder:<id>` |
|
||||
| Compétences | `agent_skills` CRUD (`/api/agent/skills` : GET/POST/DELETE, apply, import/export), galerie de 17 presets (`app/services/skill_gallery.py`), 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) | UI « Gérer » (CRUD complet : **`PATCH /skills/{id}` à créer**), UI « Parcourir » (la galerie n'est accessible que via la palette `/`), libellés affichés type « Deep research » |
|
||||
| Canevas | `GET /api/page-templates` (built-ins `app/services/block_templates.py` + personnels), `POST /api/page-templates/{id}/use` (`app/routers/board/page_api.py:75,135`) | Menu dans le panneau agent, action « insérer dans le document ouvert », canevas « design system » |
|
||||
| Connecteurs | Rien (0 hit `connector` dans `app/`). Natifs déjà branchés ailleurs : Gitea (`gitea_client.py`), GitHub (`github_adapter.py`), Web (`web_search.py` + tool `fetch_url` v7.46) | Table, service d'adapters, catalogue UI, tools agent, OAuth Google/M365, Discord/Telegram, MCP |
|
||||
| Plugins | Rien | Registre, catalogue UI, **câblage réel** (désactivation effective) |
|
||||
| Mémoire | Rien (l'historique de la conversation est déjà envoyé, ce n'est pas de la mémoire) | Table, service d'extraction, injection conditionnelle, toggle persisté |
|
||||
|
||||
## 3. Découpage front (Alpine)
|
||||
|
||||
Tout vit dans `static/js/agent_panel_2.js` (composant `agentPanel()`) + un bloc de
|
||||
markup dans `app/templates/agent_panel.html`, sans nouveau fichier JS.
|
||||
|
||||
**État ajouté**
|
||||
|
||||
```js
|
||||
plusOpen: false, // menu ouvert
|
||||
plusSection: 'root', // 'root' | 'files' | 'skills' | 'connectors' | 'canvases'
|
||||
plusFocus: -1, // index de l'item focalisé
|
||||
plusItems: [], // items de la section courante (fetch si besoin)
|
||||
memoryOn: true, // état du toggle (phase 4)
|
||||
```
|
||||
|
||||
**Items = données, pas du markup en dur** : un tableau `FD_PLUS_MENU`
|
||||
`{key, icon, label, sub, action, phase}` → le rendu et la navigation clavier
|
||||
(↑/↓/Entrée/Échap) sont une seule boucle. Les sections `phase > livrée` sont
|
||||
`disabled: true`.
|
||||
|
||||
**Réemploi** : le conteneur et les styles `.fd-ap-mention-menu` /
|
||||
`.fd-mention-item` (`agent_panel.html:171-181`) servent déjà exactement ce
|
||||
comportement — on duplique le CSS minimal (`.fd-plus-*`) et on garde la logique de
|
||||
focus/scroll (`moveMentionFocus`, `_scrollMentionItem`) comme modèle.
|
||||
|
||||
**Entrées existantes préservées** : la touche `@` dans le composer et le mode
|
||||
recherche du `+` continuent de fonctionner à l'identique (régression zéro) ; le `+`
|
||||
devient seulement *l'entrée en menu* au lieu d'ouvrir directement la liste.
|
||||
|
||||
## 4. Backend par phase
|
||||
|
||||
| Phase | Existant réutilisé | À créer |
|
||||
|---|---|---|
|
||||
| 1 — menu + fichiers/répertoires | `/api/agent/mentions`, `/api/local-workspace/tree?folder=`, enfants de page (`parent_id`, `local_workspace.py:243`) | Route(s) de parcours adaptée(s) au panneau (shape items compatible mention), jeton `folder:<id>` dans `ContextBuilder` |
|
||||
| 2 — compétences | `/api/agent/skills` (GET/POST/DELETE/apply/import/export), `/api/agent/skills/gallery` + install | `PATCH /api/agent/skills/{id}` (édition), 2 modales |
|
||||
| 3 — canevas | `/api/page-templates` (GET/POST/use) | Insersion dans l'éditeur ouvert (API d'insertion de blocs de l'éditeur), presets « design system » dans `block_templates.py` |
|
||||
| 4 — mémoire | `ContextBuilder`, `PATCH /conversations/{id}` (pattern du toggle) | Table `agent_memory`, `app/services/agent_memory.py`, colonne `conversations.memory_enabled`, config `AGENT_MEMORY_DEFAULT` |
|
||||
| 5 — connecteurs (socle) | `_is_public_host` (SSRF), `encrypt_secret` (`sso_provisioning.py:51`), `http_client.shared_client`, `tool_registry` | Table `agent_connectors`, `app/services/connectors/`, 3 adapters natifs (gitea/github/web), catalogue UI |
|
||||
| 6 — Google + M365 | `auth/oauth.py` (client OAuth2), `encrypt_secret`, `shared_client` | Flows PKCE Google + Microsoft Graph, table de tokens (motif `calendar_sync.py:38`), écran connecteur |
|
||||
| 7 — Discord / Telegram / Teams / MCP | Graph (phase 6), pattern de tools dynamiques du registre | 3 adapters + client MCP (`initialize`, `tools/list`, `tools/call`) |
|
||||
| 8 — plugins | — | Table `plugins`, catalogue UI, désactivation réelle (routes + UI) |
|
||||
|
||||
## 5. Modèle de données
|
||||
|
||||
```sql
|
||||
-- Phase 4 — mémoire
|
||||
ALTER TABLE conversations ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1;
|
||||
CREATE TABLE agent_memory (
|
||||
id INTEGER PRIMARY KEY,
|
||||
workspace_id INTEGER NOT NULL,
|
||||
conversation_id INTEGER, -- NULL = mémoire d'espace (partagée)
|
||||
kind TEXT NOT NULL DEFAULT 'summary', -- summary | fact
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_agent_memory_ws ON agent_memory(workspace_id, conversation_id);
|
||||
|
||||
-- Phase 5 — connecteurs
|
||||
CREATE TABLE agent_connectors (
|
||||
id INTEGER PRIMARY KEY,
|
||||
workspace_id INTEGER,
|
||||
kind TEXT NOT NULL, -- gitea | github | web | google | ms365 | discord | telegram | mcp | custom
|
||||
name TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}', -- URL, scopes, channels…
|
||||
secret_encrypted TEXT, -- Fernet (encrypt_secret, sso_provisioning.py:51)
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'unknown', -- ok | error | unknown
|
||||
last_probe_at TEXT,
|
||||
created_by INTEGER
|
||||
);
|
||||
|
||||
-- Phase 8 — plugins
|
||||
CREATE TABLE plugins (
|
||||
slug TEXT PRIMARY KEY, -- web-tools | web-clipper | automations | …
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Migrations ajoutées dans `app/migrations.py` (motif `_migration_*` existant,
|
||||
transaction par migration — A31).
|
||||
|
||||
## 6. Sécurité (non négociable)
|
||||
|
||||
- **SSRF** : toute URL de connecteur passée par `_is_public_host`
|
||||
(`app/services/importers/url_fetch.py`), re-vérifiée à chaque saut de redirection.
|
||||
- **Secrets** : jamais en clair, jamais renvoyés par l'API (champ retiré des
|
||||
sérialiseurs) — `encrypt_secret()` existant, dérivé de `app_secret_key`.
|
||||
- **Auth + CSRF** : session requise sur tout CUD ; `/api/agent` est sur la liste des
|
||||
préfixes CSRF cookie → header `X-CSRF-Token` sur les nouveaux `fetch` (helper
|
||||
`getCsrf()`).
|
||||
- **Borne de tokens** : la mémoire et les sources de connecteurs injectées dans le
|
||||
contexte sont tronquées (budget explicite) — sinon la phase 4 fait exploser le
|
||||
coût de chaque run.
|
||||
- **Plugins OFF = effet réel** : un simple drapeau affiché serait un menu qui ment
|
||||
(règle du projet).
|
||||
|
||||
## 7. Décisions techniques
|
||||
|
||||
- **D1 — Un seul menu, deux niveaux** (pas de modale racine) : ouverture instantanée,
|
||||
clavier, et le `+` reste un geste unique. Les modales ne servent qu'aux écrans
|
||||
lourds (Gérer / Parcourir / Connecteurs / Plugins).
|
||||
- **D2 — Pas de nouveau fichier JS** : tout dans `agent_panel_2.js` + markup du
|
||||
template, conforme à l'extraction A27 (un fichier par bloc).
|
||||
- **D3 — États « Bientôt » visibles** plutôt que sections masquées : le menu sert de
|
||||
spec vivante et évite d'implémenter6 features d'un coup.
|
||||
- **D4 — La mémoire est un résumé structuré, pas l'historique brut** : réinvoquer
|
||||
tout l'historique à chaque run coûterait plus cher que la réponse ; un résumé borné
|
||||
(~1–2 k tokens) + les faits saillants suffisent, avec repli déterministe hors-ligne
|
||||
(motif `ai_writing.py`).
|
||||
- **D5 — Connecteurs = registry + adapters** (interface `probe/list_sources/fetch`),
|
||||
pas un if par provider : la phase 7 (MCP) ajoute des adapters sans toucher au socle.
|
||||
- **D6 — Canevas : « créer » par défaut, « insérer » seulement si un document est
|
||||
ouvert** — l'insertion dans l'éditeur est le point le plus fragile (blocs +
|
||||
sélection), donc livrée en second dans la phase 3.
|
||||
- **D7 — Pas de dépendance ajoutée** : OAuth via `httpx` (`shared_client`), chiffrement
|
||||
via `cryptography` déjà présent, MCP en HTTP direct.
|
||||
|
||||
## 8. Pièges du repo à respecter
|
||||
|
||||
- `ROADMAP.md` / `CHANGELOG.md` sont en **CRLF** : `patch` exige des lignes complètes.
|
||||
- **Routes statiques avant les wildcards** (`/skills/gallery` avant `/skills/{id}`),
|
||||
sinon 422 sur un id non numérique.
|
||||
- **Events `AgentEngine.run()` plats** (`{"type": …, "content": …}`), pas enveloppés
|
||||
dans `data`.
|
||||
- Bumper **`VERSION` + `app/main.py`** à chaque phase, régénérer
|
||||
`docs/openapi-v2.json` (CRLF, sans newline final) si de nouvelles routes sortent.
|
||||
- Le panneau agent est rendu sur **toutes les pages** (`base.html:2555`) : le JS doit
|
||||
rester défensif (`window.appState` absent, page non authentifiée).
|
||||
|
||||
## 9. Critères d'acceptation par phase
|
||||
|
||||
1. **Phase 1** : le `+` ouvre le menu, « Parcourir… » montre l'arborescence, un
|
||||
dossier épinglé apparaît en chip et son contenu est bien dans le contexte du run.
|
||||
2. **Phase 2** : créer/éditer/supprimer un skill depuis la modale, l'installer depuis
|
||||
la galerie, il apparaît dans la palette `/` et le sous-menu.
|
||||
3. **Phase 3** : choisir un canevas crée la page (et l'insertion dans le document
|
||||
ouvert fonctionne quand un doc est édité).
|
||||
4. **Phase 4** : ON → la mémoire est injectée (test qui le prouve) ; OFF → absente ;
|
||||
l'état survit au rechargement et au changement de conversation.
|
||||
5. **Phase 5** : connecteur créé/testé/supprimé, URL privée refusée (SSRF), tool
|
||||
exposé au LLM avec le bon schéma.
|
||||
6. **Phase 6** : connexion Google et Microsoft, refresh, déconnexion, aucun appel
|
||||
réseau réel dans les tests.
|
||||
7. **Phase 7** : Discord/Telegram branchés, MCP `tools/list` → outils visibles dans le
|
||||
registre.
|
||||
8. **Phase 8** : plugin OFF = route refusée + UI disparue (test de non-réintroduction).
|
||||
|
||||
Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, ROADMAP
|
||||
à jour, tag.
|
||||
|
||||
## 10. Hors périmètre
|
||||
|
||||
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
|
||||
- Synchronisation d'arborescence locale ↔ connecteur.
|
||||
- Marketplace de plugins tiers (le registre est interne à l'instance).
|
||||
- Refonte du composer / de la palette `/` (compatibilité maintenue à l'identique).
|
||||
+143
-42
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.50.2"
|
||||
"version": "7.53.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -3706,6 +3706,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/board/api/page-templates/{template_id}/blocks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"board"
|
||||
],
|
||||
"summary": "Page Template Blocks",
|
||||
"description": "v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.\n\nBuiltin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).\nCanevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le\nfront appelle ``ensureBlockIds()`` (déjà global côté éditeur).",
|
||||
"operationId": "page_template_blocks_board_api_page_templates__template_id__blocks_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "template_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Template Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "Key"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/board/library": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -15906,6 +15957,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/skills/{skill_id}": {
|
||||
"patch": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Update Skill",
|
||||
"description": "Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).\n\nSeuls les champs présents dans ``body`` sont mis à jour ; la liste des\ncolonnes est figée ici (jamais interpolée depuis la requête).",
|
||||
"operationId": "update_skill_api_agent_skills__skill_id__patch",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "skill_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Skill Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Delete Skill",
|
||||
"operationId": "delete_skill_api_agent_skills__skill_id__delete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "skill_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Skill Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/skills/{skill_id}/apply": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -16099,46 +16240,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/skills/{skill_id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Delete Skill",
|
||||
"operationId": "delete_skill_api_agent_skills__skill_id__delete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "skill_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Skill Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/mentions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -29889,4 +29990,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+433
-4
@@ -32,6 +32,30 @@
|
||||
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack',
|
||||
template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'}
|
||||
];
|
||||
// ── Menu + (v7.51.0) : sections à deux niveaux. `soon` = section pas encore
|
||||
// livrée → affichée mais non cliquable (le menu ne promet rien que le code ne fait).
|
||||
var FD_PLUS_MENU = [
|
||||
{key:'files', icon:'📎', label:'Ajouter des fichiers ou répertoires',
|
||||
sub:'Rechercher ou parcourir le workspace', action:'files', chev:true},
|
||||
{key:'skills', icon:'✨', label:'Compétences-skills',
|
||||
sub:'Deep research, Skill-creator… · Gérer · Parcourir', action:'skills', chev:true},
|
||||
{key:'connectors', icon:'🔌', label:'Connecteurs',
|
||||
sub:'Parcourir / connecteur personnalisé — bientôt (phase 5)', disabled:true},
|
||||
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
|
||||
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
|
||||
{key:'plugins', icon:'🧩', label:'Add plugins',
|
||||
sub:'Modules on/off — bientôt (phase 8)', disabled:true},
|
||||
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
|
||||
sub:'Mémoire persistante — bientôt (phase 4)', disabled:true}
|
||||
];
|
||||
var FD_PLUS_FILES = [
|
||||
{key:'f-search', icon:'🔍', label:'Rechercher…',
|
||||
sub:'Documents, bases, pages par nom', action:'search', chev:true},
|
||||
{key:'f-browse', icon:'📁', label:'Parcourir…',
|
||||
sub:'Dossiers et fichiers du workspace', action:'browse', chev:true}
|
||||
];
|
||||
// Libellés affichés des skills builtin (le slug /commande reste inchangé).
|
||||
var FD_SKILL_ALIAS = {'research': 'Deep research', 'create-new-skill': 'Skill-creator'};
|
||||
|
||||
function agentPanel(){
|
||||
return {
|
||||
@@ -45,6 +69,11 @@
|
||||
contextChips: [], skills: [], gallery: [],
|
||||
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
|
||||
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
||||
plusOpen: false, plusSection: 'root', plusFocus: -1, plusLoading: false,
|
||||
plusBrowse: [], plusCrumb: [],
|
||||
skillForm: {id: null, name: '', description: '', prompt_template: ''},
|
||||
galleryFilter: '',
|
||||
canvases: [], canvas: null,
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
toastMsg: '', toastErr: false, _toastTimer: null,
|
||||
_livePh: null,
|
||||
@@ -469,7 +498,8 @@
|
||||
var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨',
|
||||
label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id};
|
||||
this.contextChips.push(pin);
|
||||
this.clearComposer();
|
||||
// Ne jamais effacer un message déjà tapé (le token s'ajoute à la fin).
|
||||
if(!this.composerText().trim()) this.clearComposer();
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
},
|
||||
@@ -1070,12 +1100,397 @@
|
||||
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
|
||||
},
|
||||
|
||||
// ── Mentions & contexte épinglé (@ / +) ──
|
||||
// ── Menu + (sections) & mentions (@ / recherche) ──
|
||||
toggleAddPicker(){
|
||||
if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; }
|
||||
this.openMentionPicker('+');
|
||||
if(this.mentionOpen){
|
||||
this.mentionOpen = false; this.mentionQuery = '';
|
||||
this._mentionFrom = null; this._atNode = null;
|
||||
}
|
||||
if(this.plusOpen){ this.closePlus(); return; }
|
||||
this.plusOpen = true;
|
||||
this.plusSection = 'root';
|
||||
this.plusFocus = 0;
|
||||
},
|
||||
closePlus(){
|
||||
this.plusOpen = false;
|
||||
this.plusSection = 'root';
|
||||
this.plusCrumb = [];
|
||||
this.plusBrowse = [];
|
||||
this.plusFocus = -1;
|
||||
},
|
||||
get plusTitle(){
|
||||
var s = this.plusSection;
|
||||
if(s === 'files') return 'Fichiers & répertoires';
|
||||
if(s === 'browse') return 'Parcourir le workspace';
|
||||
if(s === 'skills') return 'Compétences-skills';
|
||||
if(s === 'skills-manage') return 'Gérer les compétences';
|
||||
if(s === 'skills-gallery') return 'Parcourir les compétences';
|
||||
if(s === 'skills-edit') return this.skillForm.id ? 'Modifier le skill' : 'Nouveau skill';
|
||||
if(s === 'canvases') return 'Design System – Canevas';
|
||||
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
|
||||
return 'Ajouter au contexte';
|
||||
},
|
||||
get plusItems(){
|
||||
var s = this.plusSection;
|
||||
if(s === 'files') return FD_PLUS_FILES;
|
||||
if(s === 'browse') return this.plusBrowse;
|
||||
if(s === 'skills') return this._skillsItems();
|
||||
if(s === 'skills-manage') return this._manageItems();
|
||||
if(s === 'skills-gallery') return this._galleryItems();
|
||||
if(s === 'skills-edit') return []; // formulaire, pas de liste
|
||||
if(s === 'canvases') return this._canvasItems();
|
||||
if(s === 'canvas') return this._canvasActionItems();
|
||||
return FD_PLUS_MENU;
|
||||
},
|
||||
// Sous-menu Compétences : builtin (alias affiché) + installés, puis les 2 actions.
|
||||
_skillsItems(){
|
||||
var out = [];
|
||||
FD_SKILLS.forEach(function(c){
|
||||
var slug = String(c.cmd).replace(/^\//, '');
|
||||
out.push({key:'bi-'+slug, icon:c.icon, label:FD_SKILL_ALIAS[slug] || c.cmd,
|
||||
sub:c.desc, action:'skill',
|
||||
suggestion:{key:'bi-'+slug, cmd:c.cmd, icon:c.icon, template:c.template, _type:'builtin'}});
|
||||
});
|
||||
(this.skills || []).forEach(function(sk){
|
||||
out.push({key:'db-'+sk.id, icon:'✨', label:sk.name,
|
||||
sub:sk.description || 'Skill enregistré', action:'skill',
|
||||
suggestion:{key:'db-'+sk.id, cmd:'/'+sk.name, icon:'✨', _type:'db', skillId:sk.id}});
|
||||
});
|
||||
out.push({key:'sep-actions', _sep:'Gérer / parcourir'});
|
||||
out.push({key:'a-manage', icon:'🛠️', label:'Gérer les compétences',
|
||||
sub:'Créer, modifier, supprimer, exporter / importer', action:'skills-manage', chev:true});
|
||||
out.push({key:'a-browse', icon:'🗂️', label:'Parcourir les compétences',
|
||||
sub:'Galerie de presets installables en 1 clic', action:'skills-gallery', chev:true});
|
||||
return out;
|
||||
},
|
||||
// Sous-section Gérer : liste des skills + création + import.
|
||||
_manageItems(){
|
||||
var out = [];
|
||||
(this.skills || []).forEach(function(sk){
|
||||
out.push({key:'m-'+sk.id, icon:'✨', label:sk.name,
|
||||
sub:sk.description || 'Cliquer pour modifier', action:'skill-edit', chev:true, skillId:sk.id});
|
||||
});
|
||||
if(!out.length) out.push({key:'m-none', _sep:'Aucun skill enregistré'});
|
||||
out.push({key:'m-new', icon:'+', label:'Nouveau skill',
|
||||
sub:'Nom, description, prompt', action:'skill-new', chev:true});
|
||||
out.push({key:'m-sep', _sep:'Import'});
|
||||
out.push({key:'m-import', icon:'📥', label:'Importer un skill (JSON)',
|
||||
sub:'Document exporté depuis une autre instance', action:'skill-import'});
|
||||
return out;
|
||||
},
|
||||
// Sous-section Parcourir : presets de la galerie, filtrables.
|
||||
_galleryItems(){
|
||||
var f = String(this.galleryFilter || '').toLowerCase();
|
||||
var out = [];
|
||||
(this.gallery || []).forEach(function(g){
|
||||
var hay = (String(g.name || '') + ' ' + (g.description || '') + ' ' + (g.slug || '')).toLowerCase();
|
||||
if(f && hay.indexOf(f) < 0) return;
|
||||
out.push({key:'g-'+g.slug, icon:g.icon || '📦', label:g.name,
|
||||
sub:g.description || 'Preset de la galerie', action:'skill-install',
|
||||
slug:g.slug, skillName:g.name});
|
||||
});
|
||||
if(!out.length) out.push({key:'g-none', _sep: f ? 'Aucun résultat' : 'Galerie vide'});
|
||||
return out;
|
||||
},
|
||||
// ── Design System – Canevas (v7.53.0) ──
|
||||
fetchCanvases(){
|
||||
var self = this;
|
||||
fetch('/board/api/page-templates').then(function(r){ return r.json(); })
|
||||
.then(function(d){ self.canvases = d.templates || []; })
|
||||
.catch(function(){ self.canvases = []; });
|
||||
},
|
||||
_canvasItems(){
|
||||
return (this.canvases || []).map(function(t){
|
||||
return {key: 'cv-' + (t.builtin ? ('b-' + t.key) : ('u-' + t.id)),
|
||||
icon: t.icon || '📄', label: t.name,
|
||||
sub: (t.description || (t.builtin ? 'Canevas intégré' : 'Canevas personnel')),
|
||||
action: 'canvas', canvas: t, chev: true};
|
||||
});
|
||||
},
|
||||
_canvasActionItems(){
|
||||
var c = this.canvas;
|
||||
if(!c) return [];
|
||||
var pageId = this._openPageId();
|
||||
return [
|
||||
{key:'cv-meta', _sep: (c.builtin ? 'Canevas intégré · ' : 'Canevas personnel · ') + c.name},
|
||||
{key:'cv-create', icon:'+', label:'Créer une page à partir du canevas',
|
||||
sub:'Nouvelle page dans le workspace courant', action:'canvas-create'},
|
||||
{key:'cv-insert', icon:'⇩', label:'Insérer dans le document ouvert',
|
||||
sub: pageId ? 'Ajoute les blocs en fin de page' : 'Aucun document éditable ouvert',
|
||||
action:'canvas-insert', disabled: !pageId},
|
||||
{key:'cv-save', icon:'⭐', label:'Enregistrer le document ouvert comme canevas',
|
||||
sub: pageId ? 'Réutilisable depuis ce menu' : 'Aucun document ouvert',
|
||||
action:'canvas-save', disabled: !pageId}
|
||||
];
|
||||
},
|
||||
// Id de la page ouverte dans l'éditeur (refId du contexte, sinon l'URL).
|
||||
_openPageId(){
|
||||
try{
|
||||
var c = window.FlowDeckCtx && window.FlowDeckCtx.current();
|
||||
if(c && c.kind === 'page' && c.refId != null && /^\d+$/.test(String(c.refId))) return String(c.refId);
|
||||
}catch{ /* volontaire */ }
|
||||
var m = /\/pages\/(\d+)/.exec(window.location.pathname);
|
||||
return m ? m[1] : null;
|
||||
},
|
||||
_canvasUrl(suffix, t){
|
||||
t = t || this.canvas;
|
||||
return t.builtin
|
||||
? ('/board/api/page-templates/0/' + suffix + '?key=' + encodeURIComponent(t.key))
|
||||
: ('/board/api/page-templates/' + t.id + '/' + suffix);
|
||||
},
|
||||
canvasCreate(){
|
||||
var self = this, c = this.canvas;
|
||||
if(!c) return;
|
||||
// le builtin se choisit par `key` DANS LE BODY (route /use), pas en query
|
||||
fetch(this._canvasUrl('use'), {
|
||||
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(c.builtin ? {key: c.key} : {})
|
||||
}).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
|
||||
.then(function(res){
|
||||
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Création impossible.', true); return; }
|
||||
self.toast('Page créée : « ' + ((res.d && res.d.title) || c.name) + ' » (Workspace).');
|
||||
self.plusSection = 'canvases'; self.plusFocus = 0;
|
||||
}).catch(function(){ self.toast('Création impossible (réseau).', true); });
|
||||
},
|
||||
canvasInsert(){
|
||||
var self = this, c = this.canvas;
|
||||
if(!c) return;
|
||||
fetch(this._canvasUrl('blocks')).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
var E = window.E;
|
||||
if(!E || !E.blocks || !d || !Array.isArray(d.blocks)){
|
||||
self.toast('Aucun document éditable ouvert.', true); return;
|
||||
}
|
||||
if(window.ensureBlockIds) window.ensureBlockIds(d.blocks);
|
||||
try{
|
||||
if(E.sync) E.sync();
|
||||
var empty = E.blocks.length === 1 && E.blocks[0].type === 'paragraph'
|
||||
&& !(E.blocks[0].content || '').trim();
|
||||
E.blocks = empty ? d.blocks : E.blocks.concat(d.blocks);
|
||||
E.dirty = true;
|
||||
if(E.autoSave) E.autoSave();
|
||||
if(E.render) E.render();
|
||||
self.toast('Canevas inséré dans le document.');
|
||||
}catch{ self.toast('Insertion impossible.', true); }
|
||||
}).catch(function(){ self.toast('Lecture du canevas impossible.', true); });
|
||||
},
|
||||
canvasSave(){
|
||||
var self = this, c = this.canvas;
|
||||
var pageId = this._openPageId();
|
||||
if(!c || !pageId) return;
|
||||
var def = (this.activeContext && this.activeContext.label) || c.name || 'Mon canevas';
|
||||
var name = window.prompt('Nom du canevas à enregistrer :', def);
|
||||
if(!name || !name.trim()) return;
|
||||
name = name.trim();
|
||||
fetch('/board/api/page-templates', {
|
||||
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, page_id: Number(pageId)})
|
||||
}).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
|
||||
.then(function(res){
|
||||
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Enregistrement impossible.', true); return; }
|
||||
self.toast('Canevas enregistré : « ' + name + ' »');
|
||||
self.fetchCanvases();
|
||||
}).catch(function(){ self.toast('Enregistrement impossible (réseau).', true); });
|
||||
},
|
||||
_plusSelectable(){
|
||||
var out = [];
|
||||
this.plusItems.forEach(function(it, i){ if(!it.disabled && !it._sep) out.push(i); });
|
||||
return out;
|
||||
},
|
||||
movePlusFocus(delta){
|
||||
var sel = this._plusSelectable();
|
||||
if(!sel.length) return;
|
||||
var idx = sel.indexOf(this.plusFocus);
|
||||
if(idx < 0) idx = delta > 0 ? -1 : 0;
|
||||
this.plusFocus = sel[(idx + delta + sel.length) % sel.length];
|
||||
this._scrollPlusItem();
|
||||
},
|
||||
_scrollPlusItem(){
|
||||
var menu = this.$refs && this.$refs.plusMenu;
|
||||
if(!menu) return;
|
||||
var el = menu.querySelector('.fd-mention-item.focus');
|
||||
if(el && el.scrollIntoView){ menu.scrollTop = el.offsetTop - menu.clientHeight / 2 + el.clientHeight / 2; }
|
||||
},
|
||||
plusAction(it){
|
||||
if(!it || it.disabled || it._sep) return;
|
||||
if(it.action === 'files'){ this.plusSection = 'files'; this.plusFocus = 0; return; }
|
||||
if(it.action === 'search'){ this.closePlus(); this.openMentionPicker('+'); return; }
|
||||
if(it.action === 'browse'){
|
||||
this.plusSection = 'browse'; this.plusCrumb = []; this.plusLoadBrowse(null); return;
|
||||
}
|
||||
// ── Compétences (v7.52.0) ──
|
||||
if(it.action === 'skills'){ this.plusSection = 'skills'; this.plusFocus = 0; return; }
|
||||
if(it.action === 'skill'){ this.closePlus(); this.useSuggestion(it.suggestion); return; }
|
||||
if(it.action === 'skills-manage'){
|
||||
this.plusSection = 'skills-manage'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'skills-gallery'){
|
||||
this.galleryFilter = ''; this.fetchSkills();
|
||||
this.plusSection = 'skills-gallery'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'skill-new'){
|
||||
this.skillForm = {id:null, name:'', description:'', prompt_template:''};
|
||||
this.plusSection = 'skills-edit'; this.plusFocus = -1; return;
|
||||
}
|
||||
if(it.action === 'skill-edit'){
|
||||
var sk = null, wanted = it.skillId;
|
||||
(this.skills || []).forEach(function(x){ if(x.id === wanted) sk = x; });
|
||||
if(!sk) return;
|
||||
this.skillForm = {id: sk.id, name: sk.name, description: sk.description || '',
|
||||
prompt_template: sk.prompt_template || ''};
|
||||
this.plusSection = 'skills-edit'; this.plusFocus = -1; return;
|
||||
}
|
||||
if(it.action === 'skill-install'){
|
||||
this.installGallerySkill(it.slug, it.icon, it.skillName); return;
|
||||
}
|
||||
if(it.action === 'skill-import'){
|
||||
if(this.$refs.skillImport) this.$refs.skillImport.click();
|
||||
return;
|
||||
}
|
||||
// ── Design System – Canevas (v7.53.0) ──
|
||||
if(it.action === 'canvases'){
|
||||
this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'canvas'){
|
||||
this.canvas = it.canvas; this.plusSection = 'canvas'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'canvas-create'){ this.canvasCreate(); return; }
|
||||
if(it.action === 'canvas-insert'){ this.canvasInsert(); return; }
|
||||
if(it.action === 'canvas-save'){ this.canvasSave(); return; }
|
||||
if(it._folder){ // entrer dans un sous-dossier
|
||||
this.plusCrumb.push({id: it._folder, name: it.label});
|
||||
this.plusLoadBrowse(it._folder);
|
||||
return;
|
||||
}
|
||||
if(it.token){ this._mentionFrom = null; this._atNode = null; this.closePlus(); this.pickMention(it); return; }
|
||||
this.closePlus(); // sections futures (phases 2-8)
|
||||
},
|
||||
plusBack(){
|
||||
var sec = this.plusSection;
|
||||
if(sec === 'browse' && this.plusCrumb.length){
|
||||
this.plusCrumb.pop();
|
||||
this.plusLoadBrowse(this.plusCrumb.length ? this.plusCrumb[this.plusCrumb.length - 1].id : null);
|
||||
return;
|
||||
}
|
||||
if(sec === 'skills-edit'){ this.plusSection = 'skills-manage'; this.plusFocus = 0; return; }
|
||||
if(sec === 'skills-manage' || sec === 'skills-gallery'){
|
||||
this.plusSection = 'skills'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(sec === 'canvas'){ this.plusSection = 'canvases'; this.plusFocus = 0; return; }
|
||||
this.plusSection = 'root';
|
||||
this.plusFocus = 0;
|
||||
},
|
||||
plusGoto(i){ // fil d'Ariane : -1 = racine
|
||||
this.plusCrumb = i < 0 ? [] : this.plusCrumb.slice(0, i + 1);
|
||||
this.plusLoadBrowse(i < 0 ? null : this.plusCrumb[this.plusCrumb.length - 1].id);
|
||||
},
|
||||
plusLoadBrowse(parentId){
|
||||
// Un niveau par appel : /api/nav/menu (dossier = icon 'folder').
|
||||
var self = this;
|
||||
this.plusLoading = true;
|
||||
fetch('/api/nav/menu' + (parentId ? ('?parent_id=' + parentId) : ''))
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
var items = [];
|
||||
// icon de /api/nav/menu = nom SVG ('folder'|'edit'|'image'|'file') → emoji
|
||||
var ICONS = {folder:'📁', edit:'📄', image:'🖼️', file:'📎'};
|
||||
(d.items || []).forEach(function(x){
|
||||
var isFolder = x.icon === 'folder';
|
||||
items.push({
|
||||
key: 'br-' + x.id, icon: ICONS[x.icon] || '📄',
|
||||
label: x.name, sub: isFolder ? 'Dossier — ouvrir' : 'Document — épingler',
|
||||
token: (isFolder ? 'folder:' : 'document:') + x.id,
|
||||
type: isFolder ? 'folder' : 'document', id: x.id,
|
||||
_folder: isFolder ? x.id : null
|
||||
});
|
||||
});
|
||||
if(self.plusCrumb.length){
|
||||
var cur = self.plusCrumb[self.plusCrumb.length - 1];
|
||||
items.unshift({key: 'pin', icon: '📌',
|
||||
label: 'Épingler le dossier « ' + cur.name + ' »',
|
||||
sub: 'Ajoute le dossier entier au contexte',
|
||||
token: 'folder:' + cur.id, type: 'folder', id: cur.id});
|
||||
}
|
||||
self.plusBrowse = items;
|
||||
self.plusLoading = false;
|
||||
self.plusFocus = items.length ? 0 : -1;
|
||||
self.$nextTick(function(){ self._scrollPlusItem(); });
|
||||
})
|
||||
.catch(function(){ self.plusBrowse = []; self.plusLoading = false; self.plusFocus = -1; });
|
||||
},
|
||||
// ── Gérer les compétences : CRUD / export / import (v7.52.0) ──
|
||||
saveSkill(){
|
||||
var self = this, f = this.skillForm;
|
||||
if(!(f.name || '').trim()){ this.toast('Le nom est requis.', true); return; }
|
||||
fetch(f.id ? '/api/agent/skills/' + f.id : '/api/agent/skills', {
|
||||
method: f.id ? 'PATCH' : 'POST',
|
||||
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: f.name, description: f.description || '',
|
||||
prompt_template: f.prompt_template || ''})
|
||||
}).then(function(r){
|
||||
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
|
||||
}).then(function(res){
|
||||
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Enregistrement impossible.', true); return; }
|
||||
self.toast('Skill enregistré.');
|
||||
self.fetchSkills();
|
||||
self.plusSection = 'skills-manage'; self.plusFocus = 0;
|
||||
}).catch(function(){ self.toast('Enregistrement impossible (réseau).', true); });
|
||||
},
|
||||
deleteSkill(){
|
||||
var self = this, f = this.skillForm;
|
||||
if(!f.id) return;
|
||||
if(!window.confirm('Supprimer le skill « ' + f.name + ' » ?')) return;
|
||||
fetch('/api/agent/skills/' + f.id, {method: 'DELETE', headers: {'X-CSRF-Token': getCsrf()}})
|
||||
.then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
|
||||
.then(function(res){
|
||||
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Suppression impossible.', true); return; }
|
||||
self.toast('Skill supprimé.');
|
||||
// le chip épinglé ne doit pas survivre au skill
|
||||
self.contextChips = self.contextChips.filter(function(ch){
|
||||
return !(ch.kind === 'skill' && ch.skillId === f.id);
|
||||
});
|
||||
self.skillForm = {id: null, name: '', description: '', prompt_template: ''};
|
||||
self.fetchSkills();
|
||||
self.plusSection = 'skills-manage'; self.plusFocus = 0;
|
||||
}).catch(function(){ self.toast('Suppression impossible (réseau).', true); });
|
||||
},
|
||||
exportSkill(){
|
||||
var self = this, f = this.skillForm;
|
||||
if(!f.id) return;
|
||||
fetch('/api/agent/skills/' + f.id + '/export')
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(doc){
|
||||
var blob = new Blob([JSON.stringify(doc, null, 2)], {type: 'application/json'});
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = String((doc && doc.name) || 'skill').replace(/[^\w.-]+/g, '-') + '.json';
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(function(){ URL.revokeObjectURL(a.href); }, 1000);
|
||||
}).catch(function(){ self.toast('Export impossible.', true); });
|
||||
},
|
||||
importSkillFile(ev){
|
||||
var self = this;
|
||||
var input = ev.target, file = input.files && input.files[0];
|
||||
if(!file) return;
|
||||
file.text().then(function(txt){
|
||||
var payload;
|
||||
try{ payload = JSON.parse(txt); }
|
||||
catch{ self.toast('Fichier JSON invalide.', true); return; }
|
||||
return fetch('/api/agent/skills/import', {
|
||||
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({payload: payload, overwrite: true})
|
||||
}).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
|
||||
.then(function(res){
|
||||
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Import impossible.', true); return; }
|
||||
self.toast('Skill importé : ' + ((res.d && res.d.name) || '') + ' (pas de doublon).');
|
||||
self.fetchSkills();
|
||||
self.plusSection = 'skills-manage'; self.plusFocus = 0;
|
||||
});
|
||||
}).catch(function(){ self.toast('Lecture du fichier impossible.', true); })
|
||||
.finally(function(){ input.value = ''; });
|
||||
},
|
||||
openMentionPicker(from){
|
||||
this.plusOpen = false;
|
||||
this.mentionOpen = true;
|
||||
this._mentionFrom = from || '+';
|
||||
this.mentionQuery = '';
|
||||
@@ -1177,6 +1592,7 @@
|
||||
onInput(){
|
||||
this.resizeInput();
|
||||
this.slashDismiss = false;
|
||||
if(this.plusOpen) this.closePlus(); // tape = on écrit le message, le menu se referme
|
||||
var text = this.composerText();
|
||||
this.input = text;
|
||||
var cmdNow = /^\s*\//.test(text);
|
||||
@@ -1249,16 +1665,29 @@
|
||||
}catch { /* volontaire */ }
|
||||
}
|
||||
if(key === 'Escape'){
|
||||
if(this.plusOpen){ e.preventDefault(); e.stopPropagation(); this.closePlus(); return; }
|
||||
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
|
||||
if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
|
||||
return; // laisse fermer le panneau
|
||||
}
|
||||
if(key === 'ArrowDown' || key === 'ArrowUp'){
|
||||
var delta = key === 'ArrowDown' ? 1 : -1;
|
||||
if(this.plusOpen){ e.preventDefault(); this.movePlusFocus(delta); return; }
|
||||
if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
|
||||
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
|
||||
return;
|
||||
}
|
||||
// Entrée / Tab avec le menu + ouvert
|
||||
if(this.plusOpen){
|
||||
if(key === 'Enter' || key === 'Tab'){
|
||||
e.preventDefault();
|
||||
var psel = this._plusSelectable();
|
||||
if(!psel.length) return;
|
||||
var pidx = psel.indexOf(this.plusFocus) >= 0 ? this.plusFocus : psel[0];
|
||||
this.plusAction(this.plusItems[pidx]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Entrée / Tab avec un menu ouvert (skills / mentions)
|
||||
if(this.mentionOpen){
|
||||
var mSel = this._selectableMentionItems();
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""v7.51.0 — Menu + de l'assistant : sections, parcours fichiers/répertoires,
|
||||
jeton ``folder:<id>`` résolu par ContextBuilder."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.context_builder import ContextBuilder
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users LIMIT 1").fetchone()
|
||||
return row["id"] if row else 1
|
||||
|
||||
|
||||
def _folder_with_children(n_children: int = 2, body: str = "Contenu enfant.") -> int:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('ws', 'Dossier Specs', '', 'blocks', 'Workspace')"
|
||||
)
|
||||
fid = cur.lastrowid
|
||||
for i in range(n_children):
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, "
|
||||
"parent_section, parent_id) VALUES ('ws', ?, ?, 'markdown', 'Private', ?)",
|
||||
(f"Spec {i}", body * 10 if i else body, fid),
|
||||
)
|
||||
conn.commit()
|
||||
return fid
|
||||
|
||||
|
||||
def test_folder_mention_resolves_children(client):
|
||||
"""folder:<id> = titre du dossier + contenu de ses documents directs."""
|
||||
fid = _folder_with_children()
|
||||
ctx = ContextBuilder(_uid()).build(mentions=[f"folder:{fid}"])
|
||||
assert "Dossier Specs" in ctx
|
||||
assert "Contenu enfant." in ctx
|
||||
|
||||
|
||||
def test_folder_mention_truncates_on_budget(client):
|
||||
"""Le contenu d'un dossier est borné (~12k) : un gros dossier n'explose pas le prompt."""
|
||||
fid = _folder_with_children(n_children=12, body="X" * 800)
|
||||
ctx = ContextBuilder(_uid()).build(mentions=[f"folder:{fid}"])
|
||||
assert "tronqué" in ctx
|
||||
assert len(ctx) < 20000
|
||||
|
||||
|
||||
def test_folder_mention_unknown_id(client):
|
||||
ctx = ContextBuilder(_uid()).build(mentions=["folder:999999"])
|
||||
assert "introuvable" in ctx
|
||||
|
||||
|
||||
def test_browse_contract_nav_menu(client):
|
||||
"""Contrat que le parcours du menu + consomme : dossier = icon 'folder',
|
||||
un niveau par appel via parent_id."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('BrowseWS', 1)")
|
||||
ws_id = cur.lastrowid
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||||
"parent_section, parent_id) VALUES ('BrowseWS', ?, 'Dossier A', '', 'blocks', "
|
||||
"'Workspace', NULL)",
|
||||
(ws_id,),
|
||||
)
|
||||
folder_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||||
"parent_section, parent_id) VALUES ('BrowseWS', ?, 'Note enfant', '', 'markdown', "
|
||||
"'Private', ?)",
|
||||
(ws_id, folder_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
root = client.get(f"/api/nav/menu?workspace_id={ws_id}").json()["items"]
|
||||
names = {i["name"]: i for i in root}
|
||||
assert names["Dossier A"]["icon"] == "folder"
|
||||
|
||||
kids = client.get(f"/api/nav/menu?workspace_id={ws_id}&parent_id={folder_id}").json()["items"]
|
||||
kid = {i["name"]: i for i in kids}["Note enfant"]
|
||||
assert kid["icon"] != "folder"
|
||||
assert kid["url"] == f"/pages/{kid['id']}"
|
||||
|
||||
|
||||
def test_agent_panel_renders_plus_menu(client):
|
||||
"""La page rend bien le menu + (markup + bouton)."""
|
||||
resp = client.get("/accounts")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "fd-ap-plus-menu" in html
|
||||
assert "fd-ap-add" in html
|
||||
assert "Menu d'ajout au contexte" in html
|
||||
|
||||
|
||||
def test_plus_menu_sections_and_disabled_states():
|
||||
"""Les 6 sections du menu + existent ; seules les sections déjà livrées sont
|
||||
actives (le menu ne promet rien que le code ne fait)."""
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
|
||||
assert f"key:{key}" in root, key
|
||||
assert "action:'files'" in root and "action:'skills'" in root
|
||||
# phases 1-3 livrées → il reste 3 sections « bientôt » (connecteurs, plugins, mémoire)
|
||||
assert root.count("disabled:true") == 3
|
||||
|
||||
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'search'" in files and "action:'browse'" in files
|
||||
assert "folder:" in src and "document:" in src
|
||||
|
||||
|
||||
def test_folder_chip_token_reaches_run_payload():
|
||||
"""Le jeton folder:<id> épinglé part dans payload.mentions (contrat front→agent)."""
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
# composerMentions() pioche dans contextChips[].token : le parcours doit
|
||||
# produire des items avec token folder:/document:.
|
||||
assert "token: 'folder:' + cur.id" in src
|
||||
assert "token: (isFolder ? 'folder:' : 'document:') + x.id" in src
|
||||
# ... et le backend sait les résoudre.
|
||||
cb = (ROOT / "app" / "services" / "context_builder.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'm.startswith("folder:")' in cb
|
||||
assert "def _single_folder" in cb
|
||||
@@ -0,0 +1,114 @@
|
||||
"""v7.52.0 — Menu + : Compétences-skills (« Gérer » + « Parcourir »).
|
||||
|
||||
Couvre le nouveau ``PATCH /api/agent/skills/{id}``, l'import sans doublon et le
|
||||
câblage du sous-menu (sections, alias affichés, formulaire)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from conftest import anon_csrf
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
|
||||
|
||||
NEW_SKILL = {
|
||||
"name": "synthese-reunion",
|
||||
"description": "Synthèse de réunion",
|
||||
"prompt_template": "Résume cette réunion en décisions et actions.",
|
||||
}
|
||||
|
||||
|
||||
def _create(client, **overrides):
|
||||
body = {**NEW_SKILL, **overrides}
|
||||
r = client.post("/api/agent/skills", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_skill_patch_updates_fields(client):
|
||||
sid = _create(client)
|
||||
r = client.patch(f"/api/agent/skills/{sid}", json={
|
||||
"name": "synthese-v2", "description": "Version 2",
|
||||
"prompt_template": "Résume en 5 points.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "updated"
|
||||
|
||||
skills = {s["id"]: s for s in client.get("/api/agent/skills").json()["skills"]}
|
||||
assert skills[sid]["name"] == "synthese-v2"
|
||||
assert skills[sid]["description"] == "Version 2"
|
||||
assert skills[sid]["prompt_template"] == "Résume en 5 points."
|
||||
|
||||
|
||||
def test_skill_patch_partial_and_validation(client):
|
||||
sid = _create(client)
|
||||
# mise à jour partielle : un seul champ ne touche pas les autres
|
||||
assert client.patch(f"/api/agent/skills/{sid}", json={"description": "Seul champ"}).status_code == 200
|
||||
skills = {s["id"]: s for s in client.get("/api/agent/skills").json()["skills"]}
|
||||
assert skills[sid]["description"] == "Seul champ"
|
||||
assert skills[sid]["prompt_template"] == NEW_SKILL["prompt_template"]
|
||||
|
||||
assert client.patch(f"/api/agent/skills/{sid}", json={}).status_code == 400
|
||||
assert client.patch(f"/api/agent/skills/{sid}", json={"name": " "}).status_code == 400
|
||||
assert client.patch("/api/agent/skills/999999", json={"name": "ghost"}).status_code == 404
|
||||
|
||||
|
||||
def test_skill_patch_requires_session(client):
|
||||
sid = _create(client)
|
||||
anon_csrf(client) # CSRF valide mais plus de session → on veut le 401 de la route
|
||||
assert client.patch(f"/api/agent/skills/{sid}", json={"name": "pirate"}).status_code == 401
|
||||
|
||||
|
||||
def test_import_skill_has_no_duplicate(client):
|
||||
payload = {"name": "skill-importe", "prompt_template": "Fais X.", "description": "import"}
|
||||
first = client.post("/api/agent/skills/import", json={"payload": payload})
|
||||
assert first.status_code == 200 and first.json()["status"] == "imported"
|
||||
|
||||
# même payload, sans overwrite → 409 (pas de doublon silencieux)
|
||||
again = client.post("/api/agent/skills/import", json={"payload": payload})
|
||||
assert again.status_code == 409
|
||||
# avec overwrite → mise à jour de la même ligne
|
||||
over = client.post("/api/agent/skills/import", json={"payload": payload, "overwrite": True})
|
||||
assert over.status_code == 200 and over.json()["status"] == "updated"
|
||||
|
||||
names = [s["name"] for s in client.get("/api/agent/skills").json()["skills"]]
|
||||
assert names.count("skill-importe") == 1
|
||||
|
||||
|
||||
def test_export_then_roundtrip_import(client):
|
||||
sid = _create(client)
|
||||
doc = client.get(f"/api/agent/skills/{sid}/export").json()
|
||||
client.delete(f"/api/agent/skills/{sid}")
|
||||
back = client.post("/api/agent/skills/import", json={"payload": doc, "overwrite": True})
|
||||
assert back.status_code == 200, back.text
|
||||
assert back.json()["name"] == NEW_SKILL["name"]
|
||||
|
||||
|
||||
def test_skills_menu_sections_enabled(client):
|
||||
"""Sous-menu Compétences actif : alias affichés, Gérer + Parcourir, CRUD branché."""
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'skills'" in root
|
||||
assert "bientôt (phase 2)" not in root
|
||||
|
||||
alias = src.split("var FD_SKILL_ALIAS = ", 1)[1].split(";", 1)[0]
|
||||
assert "research" in alias and "Deep research" in alias
|
||||
assert "create-new-skill" in alias and "Skill-creator" in alias
|
||||
|
||||
for action in ("'skills-manage'", "'skills-gallery'", "'skill-new'",
|
||||
"'skill-edit'", "'skill-install'", "'skill-import'"):
|
||||
assert action in src, action
|
||||
for method in ("saveSkill()", "deleteSkill()", "exportSkill()", "importSkillFile"):
|
||||
assert method in src, method
|
||||
# PATCH utilisé pour l'édition (pas de POST qui recréerait la ligne)
|
||||
assert "method: f.id ? 'PATCH' : 'POST'" in src
|
||||
|
||||
|
||||
def test_skills_menu_markup_rendered(client):
|
||||
html = PANEL_HTML.read_text(encoding="utf-8")
|
||||
assert "fd-plus-form" in html
|
||||
assert 'x-ref="skillImport"' in html
|
||||
assert "Gérer les compétences" in JS_PATH.read_text(encoding="utf-8")
|
||||
resp = client.get("/accounts")
|
||||
assert resp.status_code == 200
|
||||
assert "fd-plus-form" in resp.text
|
||||
@@ -0,0 +1,73 @@
|
||||
"""v7.53.0 — Menu + : Design System – Canevas (créer / insérer / enregistrer)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
|
||||
|
||||
def test_canvases_list_includes_design_system(client):
|
||||
tpl = {t["key"]: t for t in client.get("/board/api/page-templates").json()["templates"] if t.get("builtin")}
|
||||
assert "design_system" in tpl
|
||||
assert tpl["design_system"]["name"] == "Design System"
|
||||
assert tpl["design_system"]["icon"] == "🎨"
|
||||
|
||||
|
||||
def test_blocks_route_builtin(client):
|
||||
r = client.get("/board/api/page-templates/0/blocks", params={"key": "design_system"})
|
||||
assert r.status_code == 200, r.text
|
||||
blocks = r.json()["blocks"]
|
||||
assert isinstance(blocks, list) and blocks
|
||||
assert blocks[0]["type"] == "heading_1" and blocks[0]["content"] == "Design System"
|
||||
# les builtins sont livrés sans id : le front appelle ensureBlockIds()
|
||||
assert "id" not in blocks[0]
|
||||
|
||||
assert client.get("/board/api/page-templates/0/blocks", params={"key": "nope"}).status_code == 404
|
||||
empty = client.get("/board/api/page-templates/0/blocks", params={"key": "empty"})
|
||||
assert empty.status_code == 200 and empty.json()["blocks"]
|
||||
|
||||
|
||||
def test_blocks_route_user_template(client):
|
||||
blocks = [{"type": "heading_1", "content": "Mon canevas"},
|
||||
{"type": "paragraph", "content": "Contenu."}]
|
||||
created = client.post("/board/api/page-templates", json={"name": "Mon canevas", "blocks": blocks})
|
||||
assert created.status_code == 200, created.text
|
||||
tid = created.json()["id"]
|
||||
|
||||
got = client.get(f"/board/api/page-templates/{tid}/blocks")
|
||||
assert got.status_code == 200
|
||||
assert [b["content"] for b in got.json()["blocks"]] == ["Mon canevas", "Contenu."]
|
||||
|
||||
assert client.get("/board/api/page-templates/999999/blocks").status_code == 404
|
||||
|
||||
|
||||
def test_use_design_system_creates_page(client):
|
||||
r = client.post("/board/api/page-templates/0/use", json={"key": "design_system"})
|
||||
assert r.status_code == 200, r.text
|
||||
page_id = r.json()["id"]
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
assert row["content_format"] == "blocks"
|
||||
blocks = json.loads(row["content"])
|
||||
texts = [b.get("content", "") for b in blocks]
|
||||
assert "Design System" in texts
|
||||
# les ids sont assignés côté serveur (_ensure_block_ids)
|
||||
assert all(b.get("id") for b in blocks)
|
||||
|
||||
|
||||
def test_canvases_menu_wired(client):
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'canvases'" in root
|
||||
# phases 1-3 livrées → 3 sections « bientôt » restantes (connecteurs, plugins, mémoire)
|
||||
assert root.count("disabled:true") == 3
|
||||
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
|
||||
assert action in src, action
|
||||
# piège /use : la clé builtin part DANS LE BODY, pas en query string
|
||||
assert "body: JSON.stringify(c.builtin ? {key: c.key} : {})" in src
|
||||
assert "ensureBlockIds" in src
|
||||
Reference in New Issue
Block a user