From 9274dbd49ff8088475c0605d45227aecac11f709 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 12 Sep 2026 20:09:08 -0400 Subject: [PATCH] feat(ai): menu @ instantane, selecteurs agrandis et BooksLM racine (#82) 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. --- CHANGELOG.md | 17 +++++++++--- backend/main.py | 40 ++++++++++++++++++++++++++- backend/services/search.py | 29 +++++++++++++++++++ docs/ISSUES_TODOLIST.md | 1 + docs/features/ai-provider-picker.md | 12 +++++++- frontend/js/bookslm.js | 43 ++++++++++++++++++++++++++++- frontend/js/ui.js | 2 ++ frontend/style.css | 43 ++++++++++++++++------------- tests/frontend/ai.test.mjs | 41 +++++++++++++++++++++++++++ tests/test_api_main.py | 16 +++++++++++ 10 files changed, 218 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c75b0..1582c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/backend/main.py b/backend/main.py index a51b42b..dff4258 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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)"), diff --git a/backend/services/search.py b/backend/services/search.py index d58e831..fe47cf5 100644 --- a/backend/services/search.py +++ b/backend/services/search.py @@ -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} diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index fc12bed..225feeb 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -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) | --- diff --git a/docs/features/ai-provider-picker.md b/docs/features/ai-provider-picker.md index 1c9c0c3..3806917 100644 --- a/docs/features/ai-provider-picker.md +++ b/docs/features/ai-provider-picker.md @@ -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 diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 6abeb53..c6b76e4 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -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)}` diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 512d768..9ac2cf7 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -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); diff --git a/frontend/style.css b/frontend/style.css index ed136c4..d754dc0 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 3ec96fc..eef4dc1 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -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) { diff --git a/tests/test_api_main.py b/tests/test_api_main.py index 3f16485..a5cfa04 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -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):