feat: menu + — Compétences-skills « Gérer » + « Parcourir » — phase 2/8 (v7.52.0)
- Sous-menu « Compétences-skills » : 11 skills builtin + skills enregistrés,
libellés affichés « Deep research » (/research) et « Skill-creator »
(/create-new-skill) via FD_SKILL_ALIAS (slugs intacts) ; le clic épingle le
skill au contexte, même chemin que la palette /.
- « Gérer les compétences » : section du menu + formulaire intégré (nom,
description, prompt) — création, édition, suppression (chip épinglé nettoyé),
export JSON téléchargé, import fichier avec overwrite (pas de doublon).
- « Parcourir les compétences » : section galerie filtrable (nom/description/
slug), installation en 1 clic (installGallerySkill réutilisé).
- PATCH /api/agent/skills/{skill_id} créé : édition partielle, whitelist de
colonnes, 400/401/404/409. OpenAPI régénéré : 515 chemins, version 7.52.0.
- En-tête ← + titre de section dans le menu (navigation à 3 niveaux).
- Fix : épingler un skill enregistré n'efface plus le message déjà tapé
(_pinDbSkill ne vide le composer que s'il est vide).
- Tests : tests/test_v752_skills_menu.py (7) + test_v751 adapté. Suite
complète 1280 verts (-n auto), ruff 0, eslint 0 problème.
This commit is contained in:
@@ -1,5 +1,39 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## 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
|
||||
|
||||
+31
-18
@@ -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 (phase 1 ✅ 2026-10-06 · phases 2-8 planifiées)
|
||||
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-2 ✅ 2026-10-06 · phases 3-8 planifiées)
|
||||
|
||||
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
|
||||
> un menu à sections, tel que demandé :
|
||||
@@ -335,23 +335,36 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
||||
`_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
|
||||
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
|
||||
### Phase 2 — v7.52.0 — Compétences : « Gérer » + « Parcourir » · effort M ✅ (livrée 2026-10-06)
|
||||
|
||||
- [ ] **Sous-menu Compétences** — builtin + installés (badge « installé »), le clic
|
||||
insère le template dans le composer (comportement `/` actuel conservé)
|
||||
- [ ] **Libellés affichés** — alias visibles « Deep research » (`/research`) et
|
||||
« Skill-creator » (`/create-new-skill`), sans casser les slugs existants
|
||||
- [ ] **Modale « Gérer les compétences »** — CRUD sur `POST/PATCH/DELETE /api/agent/skills`
|
||||
(nom, description, prompt, outils autorisés) + export/import
|
||||
(`/skills/{id}/export`, `/skills/import`)
|
||||
- [ ] **Modale « Parcourir les compétences »** — galerie `GET /api/agent/skills/gallery`,
|
||||
installation en 1 clic, filtre
|
||||
- [ ] **Tests** — CRUD, install/désinstall, pas de doublon à l'import
|
||||
- [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
|
||||
|
||||
@@ -416,9 +429,9 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
||||
|
||||
---
|
||||
|
||||
*Plan produit le 2026-10-06 à partir du code réel — **phase 1 livrée le 2026-10-06
|
||||
(v7.51.0)**, phases 2-8 à valider une par une avant « go ». Chaque phase se clôt par
|
||||
tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
|
||||
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 et 2 livrées le
|
||||
2026-10-06 (v7.51.0, v7.52.0)**, phases 3-8 à valider une par une avant « go ».
|
||||
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.51.0",
|
||||
version="7.52.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."""
|
||||
|
||||
@@ -181,8 +181,20 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
.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}
|
||||
@@ -389,6 +401,13 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
</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">
|
||||
@@ -399,7 +418,8 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
<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}"
|
||||
<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">
|
||||
@@ -410,6 +430,21 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
</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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# V74 — Menu + de l'assistant : hub de contexte (design)
|
||||
|
||||
> **Statut** : design — **phase 1 livrée en v7.51.0 (2026-10-06)** ; phases 2-8
|
||||
> restent à livrer (le parcours utilise `GET /api/nav/menu?parent_id=` un niveau par
|
||||
> appel, pas le tree récursif — contrat : dossier = `icon === 'folder'`).
|
||||
> **Statut** : design — **phases 1 et 2 livrées (v7.51.0, v7.52.0, 2026-10-06)** ;
|
||||
> phases 3-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'`) et la gestion des skills est une **section du menu
|
||||
> avec formulaire intégré** (pas une modale) — `PATCH /api/agent/skills/{id}` ajouté.
|
||||
> **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).
|
||||
|
||||
|
||||
+92
-42
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.50.2"
|
||||
"version": "7.52.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -15906,6 +15906,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 +16189,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 +29939,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
-9
@@ -38,7 +38,7 @@
|
||||
{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},
|
||||
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',
|
||||
@@ -54,6 +54,8 @@
|
||||
{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 {
|
||||
@@ -69,6 +71,8 @@
|
||||
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: '',
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
toastMsg: '', toastErr: false, _toastTimer: null,
|
||||
_livePh: null,
|
||||
@@ -493,7 +497,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.');
|
||||
},
|
||||
@@ -1113,18 +1118,78 @@
|
||||
this.plusFocus = -1;
|
||||
},
|
||||
get plusTitle(){
|
||||
if(this.plusSection === 'files') return 'Fichiers & répertoires';
|
||||
if(this.plusSection === 'browse') return 'Parcourir le workspace';
|
||||
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';
|
||||
return 'Ajouter au contexte';
|
||||
},
|
||||
get plusItems(){
|
||||
if(this.plusSection === 'files') return FD_PLUS_FILES;
|
||||
if(this.plusSection === 'browse') return this.plusBrowse;
|
||||
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
|
||||
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;
|
||||
},
|
||||
_plusSelectable(){
|
||||
var out = [];
|
||||
this.plusItems.forEach(function(it, i){ if(!it.disabled) out.push(i); });
|
||||
this.plusItems.forEach(function(it, i){ if(!it.disabled && !it._sep) out.push(i); });
|
||||
return out;
|
||||
},
|
||||
movePlusFocus(delta){
|
||||
@@ -1142,12 +1207,41 @@
|
||||
if(el && el.scrollIntoView){ menu.scrollTop = el.offsetTop - menu.clientHeight / 2 + el.clientHeight / 2; }
|
||||
},
|
||||
plusAction(it){
|
||||
if(!it || it.disabled) return;
|
||||
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;
|
||||
}
|
||||
if(it._folder){ // entrer dans un sous-dossier
|
||||
this.plusCrumb.push({id: it._folder, name: it.label});
|
||||
this.plusLoadBrowse(it._folder);
|
||||
@@ -1157,11 +1251,16 @@
|
||||
this.closePlus(); // sections futures (phases 2-8)
|
||||
},
|
||||
plusBack(){
|
||||
if(this.plusSection === 'browse' && this.plusCrumb.length){
|
||||
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;
|
||||
}
|
||||
this.plusSection = 'root';
|
||||
this.plusFocus = 0;
|
||||
},
|
||||
@@ -1203,6 +1302,77 @@
|
||||
})
|
||||
.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;
|
||||
|
||||
@@ -97,14 +97,15 @@ def test_agent_panel_renders_plus_menu(client):
|
||||
|
||||
|
||||
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)."""
|
||||
"""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 root.count("disabled:true") == 5 # tout sauf « fichiers/répertoires »
|
||||
assert "action:'files'" in root
|
||||
assert "action:'files'" in root and "action:'skills'" in root
|
||||
# phases 1 + 2 livrées → il reste 4 sections « bientôt »
|
||||
assert root.count("disabled:true") == 4
|
||||
|
||||
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'search'" in files and "action:'browse'" in files
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user