feat(ai): menu @ instantane, selecteurs agrandis et BooksLM racine (#82)
CI / lint (push) Successful in 1m11s
CI / security (push) Successful in 47s
CI / test (push) Successful in 2m12s
CI / build (push) Successful in 45s
CI / e2e (push) Successful in 10m21s

Nouvel endpoint GET /api/vault/{vault}/paths + prechargement et filtrage client du menu @ (affichage instantane des fichiers/repertoires). Selecteurs Fournisseur/Modele agrandis (0,8rem / 34px) pour s'aligner sur le reste du site. Ajout de BooksLM au menu contextuel de la racine des vaults.
This commit is contained in:
2026-09-12 20:09:08 -04:00
parent d6cdd670b1
commit 9274dbd49f
10 changed files with 218 additions and 26 deletions
+13 -4
View File
@@ -14,6 +14,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Ajouté
- **#82 Assistant IA — menu `@` instantané** — nouvel endpoint `GET /api/vault/{vault}/paths`
(liste plate et plafonnée de l'index des chemins). Le frontend la récupère **une fois par vault**
(préchargée à l'ouverture du panneau) puis filtre **côté client** pendant la frappe : l'affichage
des fichiers/répertoires est immédiat, au lieu d'une requête réseau par caractère. Tests :
`tests/test_api_main.py` (+2), `tests/frontend/ai.test.mjs` (+1).
- **#82 Assistant IA — Section « Fournisseur & modèle » compacte** — la barre de sélection du
modèle devient **discrète** : la liste complète des types d'endpoints (Chat, Embeddings, Vision…)
est remplacée par un **bouton d'information ⓘ** qui affiche les capacités dans une **bulle** au
@@ -229,10 +235,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
statut masqué, description accessible au **survol** de l'en-tête) ; le placeholder
« Posez une question sur ces documents… » est retiré ; l'indice clavier
« Entrée pour envoyer · Ctrl+Entrée… » est retiré ; le bouton « Envoyer » devient un **emoji
compact** (✈️). Un **indicateur d'activité** apparaît pendant le traitement et détaille le
workflow : envoi, réception de la réponse, appels d'outils (`Outil : X…`), attente de
confirmation, succès ou échec. i18n FR/EN (`ai.activity_*`, `ai.composer_label`). Tests JSDOM
(`tests/frontend/ai.test.mjs` +1).
compact** (✈️). Les sélecteurs Fournisseur/Modèle adoptent la **taille des autres contrôles du
site** (police 0,8 rem, hauteur 34 px, rayon 6 px) au lieu d'être plus fins. Un **indicateur
d'activité** apparaît pendant le traitement et détaille le workflow : envoi, réception de la
réponse, appels d'outils (`Outil : X…`), attente de confirmation, succès ou échec. Le menu
contextuel de la **racine d'une vault** expose aussi « 🧠 BooksLM », comme les répertoires.
i18n FR/EN (`ai.activity_*`, `ai.composer_label`). Tests JSDOM
(`tests/frontend/ai.test.mjs` +2).
### Corrigé
+39 -1
View File
@@ -136,7 +136,7 @@ from backend.services.mutations import (
restore_backup as service_restore_backup,
)
from backend.services.recent import humanize_mtime, list_recent
from backend.services.search import advanced_search_vaults, search_paths, search_vaults
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
from backend.services.search import list_tags as service_list_tags
from backend.services.vaults import (
browse_directory,
@@ -276,6 +276,21 @@ class TreeSearchResponse(BaseModel):
results: list[TreeSearchResult] = Field(description="Matching files and directories")
class VaultPathEntry(BaseModel):
"""A single indexed path (file or directory) in a vault."""
vault: str = Field(description="Vault name")
path: str = Field(description="Full relative path")
name: str = Field(description="File or directory name")
type: str = Field(description="'file' or 'directory'")
class VaultPathsResponse(BaseModel):
"""Flat list of every indexed path in a vault (capped)."""
vault: str = Field(description="Vault name")
count: int = Field(description="Number of returned entries")
results: list[VaultPathEntry] = Field(description="Indexed files and directories")
class AdvancedSearchResultItem(BaseModel):
"""A single advanced search result with highlighted snippet."""
vault: str = Field(description="Vault name")
@@ -2580,6 +2595,29 @@ async def api_tree_search(
return search_paths(q, vault)
@app.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse)
async def api_vault_paths(
vault_name: str,
limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"),
current_user=Depends(require_auth),
):
"""Return a flat list of every indexed file and directory in a vault.
Used by the AI assistant ``@`` mention menu to filter paths instantly on
the client (one request instead of one per keystroke).
Args:
vault_name: Name of the vault.
limit: Maximum number of entries returned.
Returns:
``VaultPathsResponse`` with the vault's indexed paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return list_paths(vault_name, limit=limit)
@app.get("/api/search/advanced", response_model=AdvancedSearchResponse)
async def api_advanced_search(
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
+29
View File
@@ -113,3 +113,32 @@ def search_paths(q: str, vault: str = "all") -> dict[str, Any]:
})
return {"query": q, "vault_filter": vault, "results": results}
def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
"""Return a flat, capped list of every indexed path in a vault.
Backs the AI assistant ``@`` mention menu: fetching the whole path index
once lets the client filter files/directories instantly instead of issuing
a request per keystroke.
Args:
vault: Vault name.
limit: Maximum number of entries returned.
Returns:
``{"vault", "count", "results": [{vault, path, name, type}]}``.
"""
from backend.indexer import path_index
entries = path_index.get(vault, [])
results = [
{
"vault": vault,
"path": entry["path"],
"name": entry["name"],
"type": entry["type"],
}
for entry in entries[: max(0, limit)]
]
return {"vault": vault, "count": len(results), "results": results}
+1
View File
@@ -154,6 +154,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-12 | BUG-012, #82 | Correction + UX | `frontend/js/bookslm.js`, `frontend/js/ai.js`, `frontend/style.css`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs` | BUG-012 : le menu `@` est toujours rendu et `_mentionVault()` retombe sur le vault de contexte puis le premier vault disponible. #82 : suppression des intitulés « Fournisseur & modèle » / « Fournisseur : », description du contexte Général déplacée en info-bulle, placeholder retiré, bouton Envoyer en emoji ✈️, indicateur d'activité (envoi/réception/outils/confirmation/succès/échec), `SW_VERSION` v5. Vérifié Playwright + tests frontend 45/45. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-12 | BUG-013, BUG-014 | Correction | `frontend/js/bookslm.js`, `frontend/index.html`, `frontend/sw.js`, `tests/frontend/ai.test.mjs` | BUG-013 : liens fichiers/dossiers via `_activeVault()` (vault de contexte → sélection → premier vault). BUG-014 : navigation ↑/↓ gérée au niveau du panneau en phase de capture. Purge des caches élargie (`SW_VERSION` v6, migration `obsigate-*`). Vérifié : tests frontend 47/47, pytest 930. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-12 | BUG-015, #82 | Correction + UX | `frontend/js/bookslm.js`, `frontend/style.css`, `tests/frontend/ai.test.mjs` | BUG-015 : sélection des menus `/` et `@` rendue visible (`--bg-hover` + barre d'accent à gauche) car `--surface2` = `--bg-primary`. #82 : retrait de l'indice clavier sous la zone de saisie. Vérifié Playwright (contraste `#1f2430` vs `#0f1117` + barre accent) et tests frontend 47/47. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-12 | #82 | Amélioration perf + UX | `backend/services/search.py`, `backend/main.py`, `frontend/js/bookslm.js`, `frontend/js/ui.js`, `frontend/style.css`, `tests/test_api_main.py`, `tests/frontend/ai.test.mjs` | #82 : endpoint `GET /api/vault/{vault}/paths` + préchargement et filtrage client du menu `@` (instantané) ; sélecteurs Fournisseur/Modèle agrandis (0,8 rem / 34 px) ; « 🧠 BooksLM » ajouté au menu contextuel de la racine des vaults. Vérifié : pytest 151 (ciblés) + ruff/mypy, tests frontend 49/49. | ✅ livré (en attente vérif utilisateur) |
---
+11 -1
View File
@@ -76,7 +76,17 @@
barre d'accent à gauche (`--surface2` étant identique à `--bg-primary` en thème sombre,
la sélection était invisible) — BUG-015.
## H. Points d'attention
## H. Performance `@`, sélecteurs agrandis & BooksLM racine (#82) — ✅ livré
- [x] Nouvel endpoint `GET /api/vault/{vault}/paths` (`backend/services/search.py: list_paths`,
`backend/main.py`) : index des chemins plat et plafonné (5000 par défaut).
- [x] Frontend : `_ensurePaths()` précharge cette liste **une fois par vault** (au `openContext`),
puis `_showMentionMenu` filtre **côté client** → affichage instantané des fichiers/
répertoires, sans requête par frappe (repli serveur si le cache est vide).
- [x] Sélecteurs Fournisseur/Modèle agrandis pour s'aligner sur les autres contrôles
(police 0,8 rem, hauteur 34 px, rayon 6 px).
- [x] Menu contextuel de la **racine d'une vault** : ajout de « 🧠 BooksLM » (`frontend/js/ui.js`).
## I. Points d'attention
- La table de capacités reste **statique** (`backend/model_capabilities.py`) : un modèle inconnu
retombe sur le défaut du fournisseur.
- Le select masqué est conservé uniquement pour la rétro-compatibilité ; ne pas le supprimer sans
+42 -1
View File
@@ -109,6 +109,10 @@ class BooksLM {
this._menuSeq = 0;
// Timer that hides the "done" activity indicator after a short delay.
this._activityHideTimer = null;
// Cache of every indexed path per vault, backing instant `@` filtering.
this._pathCache = null;
this._pathCacheVault = null;
this._pathCachePromise = null;
}
_readAgentMode() {
@@ -214,6 +218,8 @@ class BooksLM {
await this._loadContext();
this._isLoading = false;
// Warm the `@` path cache so the mention menu appears instantly.
this._ensurePaths().catch(() => { /* best effort */ });
}
/** Backwards-compatible helper: open the assistant on a directory. */
@@ -349,6 +355,31 @@ class BooksLM {
return (first && (first.name || first)) || null;
}
/**
* Fetch (once per vault) the flat list of indexed paths so the `@` menu can
* filter files/directories instantly on the client. Falls back to an empty
* list on failure; callers then use the tree-search endpoint.
*/
async _ensurePaths() {
const vault = this._activeVault();
if (!vault) return [];
if (this._pathCache && this._pathCacheVault === vault) return this._pathCache;
if (this._pathCachePromise && this._pathCacheVault === vault) return this._pathCachePromise;
this._pathCacheVault = vault;
this._pathCachePromise = api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`)
.then((data) => {
const paths = Array.isArray(data.results) ? data.results : [];
this._pathCache = paths;
return paths;
})
.catch(() => {
this._pathCache = [];
return [];
})
.finally(() => { this._pathCachePromise = null; });
return this._pathCachePromise;
}
/** Open a file mentioned in an assistant answer in the main viewer. */
async _openFileLink(path) {
const vault = this._activeVault();
@@ -856,9 +887,19 @@ class BooksLM {
if (!this._panel) return;
const seq = ++this._menuSeq;
const vault = this._activeVault();
const q = normalizeSearch(query);
try {
let results = [];
if (query) {
// Fast path: filter the prefetched flat path list on the client so the
// menu updates instantly while typing.
const cached = await this._ensurePaths();
if (seq !== this._menuSeq) return;
if (cached.length) {
const matches = q
? cached.filter((p) => normalizeSearch(p.path).includes(q))
: cached;
results = matches.slice(0, 20);
} else if (query) {
const searchVault = vault || 'all';
const data = await api(
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}`
+2
View File
@@ -1705,6 +1705,8 @@ export const ContextMenuManager = {
if (type === 'vault') {
this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly);
this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly);
this._addSeparator();
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, '')); }, false);
} else if (type === 'directory') {
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
+24 -19
View File
@@ -9193,7 +9193,7 @@ body.popup-mode .content-area {
text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
.bookslm-toolbar .ai-picker { margin-left: 0; padding-left: 0; border-left: none;
flex-wrap: wrap; row-gap: 4px; }
.bookslm-toolbar .ai-picker select { max-width: 200px; }
.bookslm-toolbar .ai-picker select { max-width: 220px; }
.bookslm-panel.fullscreen { width: 100vw; }
.bookslm-status { padding: 6px 16px; font-size: 12px; color: var(--text-secondary);
border-bottom: 1px solid var(--border); display: flex; gap: 12px; align-items: center; }
@@ -9380,40 +9380,45 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
.bookslm-modal-actions button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary); cursor: pointer; font-size: 13px; }
.bookslm-modal-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
/* ── Provider/model picker (compact, shared by assistant + editor) ── */
.ai-picker { display: inline-flex; align-items: center; gap: 6px;
font-size: 0.7rem; color: var(--text-secondary); flex-shrink: 0; position: relative; }
/* ── Provider/model picker (shared by assistant + editor) ── */
.ai-picker { display: inline-flex; align-items: center; gap: 8px;
font-size: 0.8rem; color: var(--text-secondary); flex-shrink: 0; position: relative; }
.ai-picker-label { white-space: nowrap; }
.ai-picker select { font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px; cursor: pointer; }
.ai-picker select { font-size: 0.8rem; background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; cursor: pointer;
min-height: 34px; }
.ai-picker select:hover { border-color: var(--accent); }
.ai-picker select:focus { border-color: var(--accent); outline: none; }
/* Searchable model combobox. */
.ai-picker-model-combo { position: relative; display: inline-flex; }
.ai-picker-model-trigger { display: inline-flex; align-items: center; gap: 4px; max-width: 220px;
font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; cursor: pointer; }
.ai-picker-model-trigger:hover { border-color: var(--accent); }
.ai-picker-model-trigger { display: inline-flex; align-items: center; justify-content: space-between;
gap: 6px; max-width: 240px; min-width: 120px; min-height: 34px;
font-size: 0.8rem; background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
.ai-picker-model-trigger:hover, .ai-picker-model-trigger:focus { border-color: var(--accent); outline: none; }
.ai-picker-model-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-picker-model-chevron { opacity: 0.6; font-size: 0.6rem; }
.ai-picker-model-chevron { opacity: 0.6; font-size: 0.7rem; flex-shrink: 0; }
.ai-picker-model-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 60;
min-width: 220px; width: 340px; max-width: calc(100vw - 24px); background: var(--bg-primary);
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
padding: 6px; }
.ai-picker-model-pop.hidden { display: none; }
.ai-picker-model-search { width: 100%; box-sizing: border-box; font-size: 0.72rem;
.ai-picker-model-search { width: 100%; box-sizing: border-box; font-size: 0.8rem;
background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 8px; margin-bottom: 4px; }
.ai-picker-model-list { max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; }
.ai-picker-model-option { display: block; width: 100%; text-align: left; font-size: 0.78rem;
line-height: 1.3; padding: 5px 8px; border-radius: 5px; background: none; border: none;
border-radius: 6px; padding: 7px 10px; margin-bottom: 4px; }
.ai-picker-model-search:focus { border-color: var(--accent); outline: none; }
.ai-picker-model-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; }
.ai-picker-model-option { display: block; width: 100%; text-align: left; font-size: 0.8rem;
line-height: 1.35; padding: 7px 10px; border-radius: 5px; background: none; border: none;
color: var(--text-primary); cursor: pointer; white-space: normal; overflow-wrap: anywhere; }
.ai-picker-model-option:hover { background: var(--bg-hover); }
.ai-picker-model-option.active { background: var(--bg-hover); box-shadow: inset 3px 0 0 var(--accent); }
.ai-picker-model-empty { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
.ai-picker-model-more { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
.ai-picker-model-empty { font-size: 0.78rem; color: var(--text-secondary); padding: 8px 10px; text-align: center; }
.ai-picker-model-more { font-size: 0.78rem; color: var(--text-secondary); padding: 8px 10px; text-align: center; }
/* Capability info button (ⓘ) + informational bubble. */
.ai-picker-caps-host { position: relative; display: inline-flex; }
.ai-picker-info { background: none; border: none; cursor: pointer; color: var(--text-secondary);
font-size: 0.85rem; line-height: 1; padding: 2px 4px; border-radius: 4px; }
font-size: 1rem; line-height: 1; padding: 5px 7px; border-radius: 6px; }
.ai-picker-info:hover, .ai-picker-info:focus { color: var(--accent); }
.ai-picker-info.hidden { display: none; }
.ai-picker-caps-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 60;
+41
View File
@@ -1121,6 +1121,47 @@ async function main() {
localStorage.clear();
});
// ── 42c. Fast `@` menu: client-side filtering of the prefetched path index ──
await test("mention menu filters the prefetched path index instantly", async () => {
localStorage.clear();
const calls = [];
globalThis.fetch = async (url) => {
const u = String(url);
calls.push(u);
if (u.includes("/paths")) {
return {
ok: true, status: 200,
json: async () => ({ vault: "V", count: 3, results: [
{ vault: "V", path: "notes/a.md", name: "a.md", type: "file" },
{ vault: "V", path: "notes/b.md", name: "b.md", type: "file" },
{ vault: "V", path: "projects", name: "projects", type: "directory" },
] }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._vault = "V";
await b._showMentionMenu("notes");
const menu = b._panel.querySelector(".bookslm-mention-menu");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2, "client-side filter keeps notes/*");
assert.ok(!calls.some((u) => u.includes("/api/tree-search")), "no server search once the path cache is warm");
b._panel.remove();
localStorage.clear();
});
await test("vault root context menu exposes BooksLM", async () => {
const { readFileSync } = await import("node:fs");
const src = readFileSync(path.join(JS_DIR, "ui.js"), "utf-8");
const vaultBranch = src.slice(
src.indexOf("if (type === 'vault')"),
src.indexOf("} else if (type === 'directory')")
);
assert.ok(vaultBranch.includes("BooksLM"), "BooksLM entry present in the vault root context menu");
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
+16
View File
@@ -312,6 +312,22 @@ class TestTreeSearch:
data = resp.json()
assert len(data["results"]) == 0
def test_vault_paths_returns_indexed_paths(self, client):
"""Flat path index backs the AI assistant `@` mention menu."""
resp = client.get("/api/vault/TestVault/paths")
assert resp.status_code == 200
data = resp.json()
assert data["vault"] == "TestVault"
paths = [p["path"] for p in data["results"]]
assert "note1.md" in paths
assert all("type" in p for p in data["results"])
def test_vault_paths_rejects_unknown_vault(self, client):
resp = client.get("/api/vault/DoesNotExist/paths")
assert resp.status_code in (200, 403, 404)
if resp.status_code == 200:
assert resp.json()["results"] == []
class TestSearchReplace:
def test_search_replace_invalid(self, client):