From eea2108ac26b0f2eccf5e245376ae09ba55f092c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 12 Sep 2026 13:50:14 -0400 Subject: [PATCH] fix(ai): lisibilite des modeles + contexte @ en mode General (BUG-010, BUG-011) BUG-011: popover des modeles aligne a droite (right:0), largeur 340px bornee, noms sur plusieurs lignes + title. BUG-010: la selection @ capture le vault renvoye par tree-search et _contextVault() le propage aux requetes /context et /chat, corrigeant le contexte ad-hoc en mode General. --- CHANGELOG.md | 14 +++++++++++ docs/ISSUES_TODOLIST.md | 3 +++ docs/features/ai-provider-picker.md | 11 +++++++- frontend/js/ai.js | 9 ++++--- frontend/js/bookslm.js | 39 +++++++++++++++++++---------- frontend/style.css | 12 ++++----- tests/frontend/ai.test.mjs | 35 ++++++++++++++++++++++++++ 7 files changed, 99 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c04dc..ae3315c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -223,6 +223,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Corrigé +- **BUG-011 — Assistant IA : noms de modèles illisibles dans la liste déroulante** — la liste + déroulante des modèles s'ouvrait alignée à gauche (`left: 0`) et pouvait dépasser le bord droit + de la barre latérale (ancrée à droite), rendant les noms tronqués/illisibles ; elle est + désormais **alignée à droite** du déclencheur, large de 340 px (bornée à `100vw - 24px`), et les + noms **reviennent à la ligne** (`overflow-wrap: anywhere`, police 0,78 rem) avec l'info-bulle + `title` complète. Tests JSDOM (`tests/frontend/ai.test.mjs`). + +- **BUG-010 — Assistant IA : la commande `@` n'ajoutait pas le contexte en mode Général** — les + fichiers/répertoires choisis via `@` étaient bien ajoutés sous forme de puces, mais le **vault + n'était pas transmis** : en mode Général (`_vault` nul), la requête partait sans `vault` et le + backend ignorait le contexte ad-hoc. La sélection capture maintenant le `vault` renvoyé par + `/api/tree-search`, `_contextVault()` le propage aux requêtes `/context` et `/chat`, et les + recherches suivantes restent dans ce vault. Tests JSDOM (`tests/frontend/ai.test.mjs`). + - **BUG-009 — Assistant IA : liste de modèles corrompue et clés i18n brutes** — deux causes : (1) les fichiers de locale n'étant pas *content-hashed*, un `fr.json` en cache HTTP affichait les clés brutes (`ai.model_search`, `mobile_editor.*`, …) ; le chargement i18n utilise désormais diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index b589e20..c73e29f 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -119,6 +119,8 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-007* | Assistant IA : menus `/` et `@` — navigation clavier ↑/↓ inopérante et filtrage des skills cassé par les accents | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Taper `/résumé` ou `@café`, puis ↑/↓ | `bookslm.js` : motifs Unicode `\p{L}` (détection + sélection), recherche normalisée NFD (insensible aux accents), jeton `_menuSeq` (rendus async obsolètes), `scrollIntoView` de l'élément actif | Le menu se fermait dès la saisie d'un accent ; la frappe rapide pouvait écraser le menu avec un résultat obsolète. Tests : `tests/frontend/ai.test.mjs` (+3) | | *BUG-008* | Assistant IA : commande `@` — chemins accentués + contexte ad-hoc ignoré en mode Général | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/bookslm_routes.py` | `@fichier-accentué` puis envoyer en mode Général | Frontend : détection Unicode + repli `vault=all` sans vault courant. Backend : `_resolve_system_prompt` résout le vault optionnel dès qu'un contexte `@` est présent (mode Général) | Le backend ignorait `extra_files`/`extra_directories` en mode Général (`vault_path is None`). Tests : `tests/test_bookslm.py` (+2) et `tests/frontend/ai.test.mjs` | | *BUG-009* | Assistant IA : liste de modèles corrompue (art ASCII) et clés i18n brutes (`ai.model_search`) sous OpenRouter | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/js/i18n.js`, `frontend/sw.js`, `frontend/style.css` | Ouvrir le menu modèle sous « openrouter » | Locale non content-hashée en cache HTTP → `cache:'no-store'` + bump `SW_VERSION` v4 ; styles critiques du picker appliqués **en ligne** (popover absolu, liste en colonne, options `display:block`) ; rendu plafonné à 200 modèles + indicateur « … N autres » | OpenRouter expose plusieurs centaines de modèles ; si `style.css` est en cache, la liste s'affichait en bloc inline (art ASCII) et les clés i18n brutes provenaient d'un `fr.json` obsolète. Tests : `tests/frontend/ai.test.mjs` (+1) | +| *BUG-010* | Assistant IA : la commande `@` n'ajoute pas le contexte à la requête en mode Général | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Assistant en mode Général, taper `@fichier`, sélectionner, envoyer | Capture du `vault` renvoyé par `/api/tree-search` dans la sélection `@` ; `_contextVault()` propage ce vault aux requêtes `/context` et `/chat` ; recherches `@` suivantes limitées à ce vault | En mode Général, `_vault` est nul : la requête partait sans vault et le backend ignorait `extra_files`. Tests : `tests/frontend/ai.test.mjs` (+1) | +| *BUG-011* | Assistant IA : noms de modèles illisibles dans la liste déroulante | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/style.css` | Ouvrir le menu modèle dans la barre latérale de l'assistant | Popover aligné à droite du déclencheur (`right:0`), largeur 340 px bornée à `100vw - 24px`, noms sur plusieurs lignes (`overflow-wrap:anywhere`, police 0,78 rem) + `title` | La liste s'ouvrait vers la droite et dépassait le bord de la sidebar ancrée à droite ; noms tronqués. Tests : `tests/frontend/ai.test.mjs` | ### TODOs techniques (améliorations / nouvelles tâches) @@ -144,6 +146,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-11 | BUG-006 | Correction sécurité | `.env.example` | Clé API DeepSeek réelle exposée dans `.env.example` → remplacée par un placeholder. **Rotation de la clé à faire côté DeepSeek** (présente dans l'historique Git). | 🟢 corrigé (rotation à confirmer par l'utilisateur) | | 2026-09-12 | BUG-007, BUG-008, #82 | Correction + refonte UI | `frontend/js/bookslm.js`, `frontend/js/ai.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/bookslm_routes.py`, `tests/frontend/ai.test.mjs`, `tests/test_bookslm.py` | BUG-007 : motifs Unicode + recherche NFD insensible aux accents + navigation ↑/↓ fiabilisée (jeton de séquence, scrollIntoView). BUG-008 : contexte ad-hoc `@` pris en compte en mode Général (backend) + repli `vault=all`. #82 : section « Fournisseur & modèle » compacte (recherche modèle + bulle capacités ℹ️ au survol/clic/appui long). Vérifié : tests frontend 41/41, `tests/test_bookslm.py` 47 passed. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-12 | BUG-009 | Correction | `frontend/js/ai.js`, `frontend/js/i18n.js`, `frontend/sw.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs` | BUG-009 : locale non content-hashée servie depuis le cache HTTP → clés i18n brutes ; passage en `cache:'no-store'` + `SW_VERSION` v4. Liste de modèles corrompue (OpenRouter, styles en cache) → styles critiques en ligne, options `display:block`, plafond 200 + indicateur. Vérifié : tests frontend 42/42. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-12 | BUG-010, BUG-011 | Correction | `frontend/js/bookslm.js`, `frontend/js/ai.js`, `frontend/style.css`, `tests/frontend/ai.test.mjs` | BUG-010 : capture du `vault` des résultats `@` + `_contextVault()` propagé aux requêtes `/context`/`/chat` (mode Général). BUG-011 : popover modèle aligné à droite, largeur 340 px, noms multi-lignes + `title`. Vérifié en navigateur (Playwright) et tests frontend 43/43. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/features/ai-provider-picker.md b/docs/features/ai-provider-picker.md index 765cfb6..112da50 100644 --- a/docs/features/ai-provider-picker.md +++ b/docs/features/ai-provider-picker.md @@ -43,7 +43,16 @@ `display:block`) pour rester corrects même si `style.css` est servi depuis un cache. - [x] Rendu plafonné à 200 modèles (`MODEL_RENDER_LIMIT`) + indicateur `ai.model_more`. -## E. Points d'attention +## E. Lisibilité & contexte `@` (BUG-010, BUG-011) — ✅ livré +- [x] Popover modèle **aligné à droite** du déclencheur (`right: 0`), largeur 340 px bornée à + `100vw - 24px` : ne dépasse plus le bord de la sidebar ancrée à droite (BUG-011). +- [x] Noms de modèles sur **plusieurs lignes** (`overflow-wrap: anywhere`, police 0,78 rem) avec + attribut `title` complet. +- [x] La sélection `@` **capture le vault** renvoyé par `/api/tree-search` ; `_contextVault()` + propage ce vault aux requêtes `/context` et `/chat` et les recherches suivantes restent dans + ce vault (BUG-010, mode Général). + +## F. 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/ai.js b/frontend/js/ai.js index 737f62d..25391cf 100644 --- a/frontend/js/ai.js +++ b/frontend/js/ai.js @@ -187,9 +187,9 @@ async function _buildPickerUI() { modelPop.className = 'ai-picker-model-pop hidden'; // Critical layout is applied inline too: if a stale stylesheet is served, the // popover must still stack vertically instead of flowing as a block of text. - modelPop.style.cssText = 'position:absolute;top:calc(100% + 4px);left:0;z-index:60;' - + 'min-width:220px;max-width:320px;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;'; + modelPop.style.cssText = '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;'; const modelSearch = document.createElement('input'); modelSearch.type = 'text'; @@ -296,7 +296,8 @@ async function _buildPickerUI() { option.className = 'ai-picker-model-option'; option.dataset.value = model; option.textContent = model || t('ai.model_default'); - option.style.cssText = 'display:block;width:100%;text-align:left;'; + option.title = model; + option.style.cssText = 'display:block;width:100%;text-align:left;white-space:normal;overflow-wrap:anywhere;'; if (model === modelSelect.value) option.classList.add('active'); option.addEventListener('mousedown', (e) => { e.preventDefault(); _pick(model); }); modelList.appendChild(option); diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index da3efb0..e587196 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -321,6 +321,18 @@ class BooksLM { return state.currentVault || null; } + /** + * Vault used for context/chat requests. Falls back to the vault of the first + * ad-hoc `@` entry: in General mode there is no base vault, but the selected + * files/directories still belong to a vault that must be sent to the server. + */ + _contextVault() { + if (this._vault) return this._vault; + const entry = this._adhocFiles[0] || this._adhocDirs[0] || this._images[0]; + if (entry && entry.vault) return entry.vault; + return this._resolveVault(); + } + /** Open a file mentioned in an assistant answer in the main viewer. */ async _openFileLink(path) { const vault = this._resolveVault(); @@ -378,7 +390,7 @@ class BooksLM { const payload = { mode: this._mode, - vault: this._vault || this._resolveVault(), + vault: this._contextVault(), directory: this._directory, context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], extra_files: this._adhocFiles.map((f) => f.path), @@ -667,17 +679,17 @@ class BooksLM { return chip; } - _addAdhocFile(path) { + _addAdhocFile(path, vault) { if (!path || this._adhocFiles.some((f) => f.path === path)) return; - this._adhocFiles.push({ path }); + this._adhocFiles.push({ path, vault: vault || null }); this._renderAttachments(); this._loadContext(); } - _addAdhocDir(path) { + _addAdhocDir(path, vault) { const clean = path.replace(/\/+$/, ''); if (!clean || this._adhocDirs.some((d) => d.path === clean)) return; - this._adhocDirs.push({ path: clean }); + this._adhocDirs.push({ path: clean, vault: vault || null }); this._renderAttachments(); this._loadContext(); } @@ -808,7 +820,7 @@ class BooksLM { async _showMentionMenu(query) { if (!this._panel) return; const seq = ++this._menuSeq; - const vault = this._resolveVault(); + const vault = this._contextVault(); if (!vault && !query) { // Nothing to list without a vault; a typed query can still search all. this._hideMenus(); @@ -833,7 +845,7 @@ class BooksLM { const path = r.path || r; const rawType = r.type || (this._classifyPath(path) === 'dir' ? 'dir' : 'file'); const type = rawType === 'directory' ? 'dir' : rawType; - return { id: path, label: path, type }; + return { id: path, label: path, type, vault: r.vault || null }; }); this._renderMenu('mention', items, (item) => this._selectMention(item)); } catch (e) { @@ -851,18 +863,19 @@ class BooksLM { textarea.focus(); } const path = item.id; + const vault = item.vault || this._resolveVault(); if (IMAGE_EXT_RE.test(path)) { - this._addImagePath(path); + this._addImagePath(path, vault); } else if (item.type === 'dir' || this._classifyPath(path) === 'dir') { - this._addAdhocDir(path); + this._addAdhocDir(path, vault); } else { - this._addAdhocFile(path); + this._addAdhocFile(path, vault); } } - _addImagePath(path) { + _addImagePath(path, vault) { if (!path || this._images.some((img) => img.path === path)) return; - this._images.push({ path, name: path.split('/').pop() }); + this._images.push({ path, name: path.split('/').pop(), vault: vault || null }); this._renderAttachments(); } @@ -1593,7 +1606,7 @@ class BooksLM { const payload = { mode: this._mode, - vault: this._vault || this._resolveVault(), + vault: this._contextVault(), directory: this._directory, context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], extra_files: this._adhocFiles.map((f) => f.path), diff --git a/frontend/style.css b/frontend/style.css index 845d56d..504e6cf 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9376,18 +9376,18 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; } .ai-picker-model-trigger:hover { border-color: var(--accent); } .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-pop { position: absolute; top: calc(100% + 4px); left: 0; z-index: 60; - min-width: 220px; max-width: 320px; background: var(--bg-primary); +.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; 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: 200px; overflow-y: auto; display: flex; flex-direction: column; } -.ai-picker-model-option { display: block; width: 100%; text-align: left; font-size: 0.72rem; - padding: 5px 8px; border-radius: 5px; background: none; border: none; color: var(--text-primary); - cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.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; + color: var(--text-primary); cursor: pointer; white-space: normal; overflow-wrap: anywhere; } .ai-picker-model-option:hover, .ai-picker-model-option.active { background: var(--surface2); } .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; } diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 5d7d767..1e63494 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -760,6 +760,41 @@ async function main() { b._panel.remove(); }); + // ── 35b. `@` captures the vault for General-mode ad-hoc context (BUG-010) ── + await test("mention selection stores its vault and feeds the context request", async () => { + localStorage.clear(); + globalThis.fetch = async (url) => { + if (String(url).includes("/api/tree-search")) { + return { + ok: true, status: 200, + json: async () => ({ results: [{ vault: "TestVault", path: "notes/a.md", type: "file" }] }), + }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }; + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + b._mode = MODE.GENERAL; + b._vault = null; + + await b._showMentionMenu("notes"); + b._selectMention({ id: "notes/a.md", type: "file", vault: "TestVault" }); + assert.equal(b._adhocFiles[0].vault, "TestVault", "ad-hoc entry keeps its vault"); + assert.equal(b._contextVault(), "TestVault", "context vault derives from the ad-hoc entry"); + + let posted = null; + globalThis.fetch = async (url, opts) => { + if (String(url).includes("/api/ai/bookslm/context")) posted = JSON.parse(opts.body); + return { ok: true, status: 200, json: async () => ({ files: [], scope: "documents" }) }; + }; + await b._loadContext(); + assert.equal(posted.vault, "TestVault", "context request carries the vault"); + assert.deepEqual(posted.extra_files, ["notes/a.md"]); + b._panel.remove(); + localStorage.clear(); + }); + // ── 36. Capability checklist rendering (#81) ── await test("capability list renders checked and unchecked flags", () => { const caps = {