fix(ai+editeur): BUG-046 erreur lisible et vault réel pour l'ajout IA au document; BUG-045 fin de la double scrollbar
CI / lint (push) Failing after 1m26s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 57s

BUG-046: 'Ajouter du texte au document courant' échouait avec 'Échec de
l'action: [object Object]'. Trois causes corrigées:
- continuation de confirmation sans payload -> second Appliquer POSTait
  sans 'message' (422); le payload d'origine est désormais transmis
- detail 422 FastAPI (tableau d'objets) stringifié tel quel -> nouvelle
  _responseError() qui aplatit en message lisible
- prompt documents/dossier muet sur le vault -> le modèle inventait
  'vault: test' et append_to_file échouait en silence; build_system_prompt
  nomme le vault et enjoint les outils d'écriture de l'utiliser exactement

BUG-045: double barre de défilement dans l'éditeur Editer (fichiers
longs): #editor-body overflow:auto + cm-editor height:100% sous la barre
IA -> corps et scroller CodeMirror défilant tous les deux. Flex column,
seul le scroller défile; override global .cm-scroller retiré. SW v20.

Vérifié: pytest 1038, ruff backend 0, tests frontend AI 84/84,
editor-inline 25/25, validate-imports 38 modules; Playwright sur
l'instance de test (un seul conteneur scrollable) et flux agent complet
append_to_file ok=true sur fichier réel
This commit is contained in:
2026-09-16 08:53:01 -04:00
parent e3df4bbf00
commit 3a648cdc50
10 changed files with 212 additions and 20 deletions
+30 -3
View File
@@ -87,9 +87,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
lecture (bandeau, métadonnées, tags et contenu). L'overlay reste monté mais neutralisé lecture (bandeau, métadonnées, tags et contenu). L'overlay reste monté mais neutralisé
(transparent, sans capture des clics, `z-index` conservé) car le ruban d'édition mobile y est (transparent, sans capture des clics, `z-index` conservé) car le ruban d'édition mobile y est
ancré. Retour à la lecture par ✓ (sauvegarde), ✕, Échap ou l'ouverture d'un autre fichier — ancré. Retour à la lecture par ✓ (sauvegarde), ✕, Échap ou l'ouverture d'un autre fichier —
un garde-fou dans `renderFile()` libère proprement la session (destruction CodeMirror/Yjs, trois garde-fous libèrent proprement la session (destruction CodeMirror/Yjs, retrait de
retrait de l'iframe Forge) ; l'overlay n'est conservé qu'en repli, quand la cible n'est pas le l'iframe Forge) avant qu'un rendu concurrent n'efface son DOM : `renderFile()`, l'activation
document affiché (ex. fichier créé depuis la palette). Conséquence directe : l'éditeur ne d'onglet (`TabManager.activate`, `PaneTabManager.activate`, qui vident la zone avant de
rendre) et l'événement SSE `index_updated` (un fichier modifié hors de l'app recharge le
**tampon de l'éditeur** au lieu de re-rendre la vue lecture). L'overlay n'est conservé qu'en
repli, quand la cible n'est pas le document affiché (ex. fichier créé depuis la palette).
Conséquence directe : l'éditeur ne
recouvre plus le panneau de l'assistant IA, qui peut donc mettre le document à jour **sous recouvre plus le panneau de l'assistant IA, qui peut donc mettre le document à jour **sous
les yeux de l'utilisateur** — `app_context.editing` annonce au modèle le document en cours les yeux de l'utilisateur** — `app_context.editing` annonce au modèle le document en cours
d'édition (et sa surface), et toute écriture d'outil (`edit_file`, `append_to_file`, d'édition (et sa surface), et toute écriture d'outil (`edit_file`, `append_to_file`,
@@ -102,6 +106,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Corrigé ### Corrigé
- **BUG-045 — Double barre de défilement dans l'éditeur « Editer »** : sur les
documents longs, `#editor-body` (qui gardait `overflow:auto`) et le scroller
CodeMirror débordaient simultanément — la barre d'outils IA empilée sous un
`.cm-editor` en `height:100%` faisait déborder le corps, et un ancien override
global `.cm-scroller` forçait une seconde zone défilante. La surface
d'édition est passée en flex column : le corps ne défile plus (`overflow:
hidden`), l'éditeur remplit l'espace restant et **seul le scroller CodeMirror
défile**. Repli textarea (fallback) cohérent ; la règle mobile BUG-020 est
préservée.
- **BUG-046 — « Échec de l'action : [object Object] » (assistant IA, ajout de
texte au document courant)** : trois causes corrigées ensemble. (1) La carte
de confirmation « Appliquer » reprenait un payload dont le message était
absent après une première application échouée (continuation `payload:null`)
→ réponse 422 du backend. (2) Cette réponse 422 porte un `detail` **tableau
d'objets** (format validation FastAPI) que `new Error(detail)` réduisait en
`[object Object]` dans le toast — nouvelle méthode `_responseError()` qui
aplatit tableaux et objets en message lisible. (3) Le prompt des modes
documents/dossier ne nommait pas le vault : le modèle inventait parfois
`"vault":"test"` et l'outil d'écriture échouait silencieusement — le prompt
indique désormais le vault exact et enjoint les outils `edit_file` /
`append_to_file` / `create_file` de l'utiliser. Cache service worker
`SW_VERSION` v20.
# Changelog — ObsiGate
- **#91 — Bulle utilisateur trop étroite & ancre d'envoi retardée** : le plafond de - **#91 — Bulle utilisateur trop étroite & ancre d'envoi retardée** : le plafond de
largeur était appliqué **deux fois** (wrapper 80 % × bulle 85 % ≈ 68 % du fil), d'où largeur était appliqué **deux fois** (wrapper 80 % × bulle 85 % ≈ 68 % du fil), d'où
des lignes très courtes ; le plafond est désormais porté par la seule bulle (90 %), des lignes très courtes ; le plafond est désormais porté par la seule bulle (90 %),
+13 -1
View File
@@ -414,12 +414,16 @@ def _build_directory_tree(target_dir: Path, vault_root: Path) -> str:
return "\n".join(lines) return "\n".join(lines)
def build_system_prompt(context: dict[str, Any], scope: str = "directory") -> str: def build_system_prompt(context: dict[str, Any], scope: str = "directory", vault_name: str | None = None) -> str:
"""Build a system prompt for document-scoped AI chat. """Build a system prompt for document-scoped AI chat.
Args: Args:
context: Output of collect_directory_context()/collect_files_context(). context: Output of collect_directory_context()/collect_files_context().
scope: "directory" (whole folder) or "documents" (open files). scope: "directory" (whole folder) or "documents" (open files).
vault_name: Vault the context files belong to. When set, the prompt
states it explicitly with write-tool guidance (BUG-046: without
it the model invented vault names — e.g. "test" — and every
confirmed ``append_to_file``/``edit_file`` call failed).
Returns: Returns:
System prompt string with file contents. System prompt string with file contents.
@@ -469,6 +473,14 @@ def build_system_prompt(context: dict[str, Any], scope: str = "directory") -> st
prompt += "\nFin du contexte. Réponds à la question de l'utilisateur en te basant uniquement sur ces documents." prompt += "\nFin du contexte. Réponds à la question de l'utilisateur en te basant uniquement sur ces documents."
if vault_name:
prompt += (
f"\n\nCes documents appartiennent au vault « {vault_name} ». Quand tu utilises un outil "
"d'écriture (`append_to_file`, `edit_file`, `create_file`), passe TOUJOURS "
f"exactement `\"vault\": \"{vault_name}\"` (jamais un nom inventé) et un `path` "
"relatif au vault, identique à celui affiché ci-dessus."
)
return prompt return prompt
+2 -2
View File
@@ -223,7 +223,7 @@ def _resolve_system_prompt(req, current_user) -> str:
recent_files=_recent_files_for_prompt(current_user), recent_files=_recent_files_for_prompt(current_user),
) )
elif effective_mode == "documents": elif effective_mode == "documents":
prompt = build_system_prompt(context, scope="documents") prompt = build_system_prompt(context, scope="documents", vault_name=req.vault)
elif context["file_count"] == 0: elif context["file_count"] == 0:
# Empty (or unreadable) directory: don't block the request. Answer as # Empty (or unreadable) directory: don't block the request. Answer as
# the General assistant would, telling the model the folder is empty so # the General assistant would, telling the model the folder is empty so
@@ -240,7 +240,7 @@ def _resolve_system_prompt(req, current_user) -> str:
"documentaire, et propose une action de création si c'est pertinent.\n" "documentaire, et propose une action de création si c'est pertinent.\n"
) )
else: else:
prompt = build_system_prompt(context, scope="directory") prompt = build_system_prompt(context, scope="directory", vault_name=req.vault)
skill_id = getattr(req, "skill", None) skill_id = getattr(req, "skill", None)
if skill_id: if skill_id:
+3
View File
@@ -154,6 +154,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-042* | [🟡 IMPORTANT] Assistant IA : liens de fichiers non fiables (« File not found: ») — pas de règle déterministe nom / dossier / chemin | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Cliquer les liens de fichiers/dossiers dans une réponse de l'assistant (noms avec espaces et/ou accents, chemin préfixé par le nom du vault) | `_classifyPath` distingue `name` (copie presse-papiers) / `dir` (révélation arborescence) / `file` (ouverture) ; `_activatePath()` résout le chemin contre l'index du vault (exact → suffixe → basename unique) avant d'agir ; espaces + accents pris en charge (classes Unicode `\p{L}\p{N}\p{M}`, comparaison normalisée NFC, markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index) ; `_splitVaultPrefix` retire un préfixe `Vault/…` et ouvre dans ce vault (`_fetchPathsForVault`) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+11) | | *BUG-042* | [🟡 IMPORTANT] Assistant IA : liens de fichiers non fiables (« File not found: ») — pas de règle déterministe nom / dossier / chemin | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Cliquer les liens de fichiers/dossiers dans une réponse de l'assistant (noms avec espaces et/ou accents, chemin préfixé par le nom du vault) | `_classifyPath` distingue `name` (copie presse-papiers) / `dir` (révélation arborescence) / `file` (ouverture) ; `_activatePath()` résout le chemin contre l'index du vault (exact → suffixe → basename unique) avant d'agir ; espaces + accents pris en charge (classes Unicode `\p{L}\p{N}\p{M}`, comparaison normalisée NFC, markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index) ; `_splitVaultPrefix` retire un préfixe `Vault/…` et ouvre dans ce vault (`_fetchPathsForVault`) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+11) |
| *BUG-043* | [🟡 IMPORTANT] Assistant IA : la liste des fournisseurs de la barre latérale ne suit pas les ajouts/retraits de clés API dans la configuration du projet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js` | Ajouter (ou supprimer) une clé de fournisseur AI dans la configuration puis observer le menu Fournisseur de l'assistant sans recharger la page | Le picker lit `/api/ai/status` **une seule fois**, à sa construction, et le panneau de l'assistant est un singleton monté pour toute la session → liste figée. Nouveau `refreshAIPickers()` (exporté par `ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (conservé même sans fournisseur configuré, donc un premier fournisseur s'y monte aussi) ; appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; une sélection dont le fournisseur n'est plus configuré est purgée de `obsigate_ai_picker` (retour au défaut + modèle effacé au lieu d'un nom fantôme) | Il fallait recharger la page pour voir un nouveau fournisseur (ou en voir disparaître un). Feature #82. Tests : `tests/frontend/ai.test.mjs` (+4) | | *BUG-043* | [🟡 IMPORTANT] Assistant IA : la liste des fournisseurs de la barre latérale ne suit pas les ajouts/retraits de clés API dans la configuration du projet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js` | Ajouter (ou supprimer) une clé de fournisseur AI dans la configuration puis observer le menu Fournisseur de l'assistant sans recharger la page | Le picker lit `/api/ai/status` **une seule fois**, à sa construction, et le panneau de l'assistant est un singleton monté pour toute la session → liste figée. Nouveau `refreshAIPickers()` (exporté par `ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (conservé même sans fournisseur configuré, donc un premier fournisseur s'y monte aussi) ; appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; une sélection dont le fournisseur n'est plus configuré est purgée de `obsigate_ai_picker` (retour au défaut + modèle effacé au lieu d'un nom fantôme) | Il fallait recharger la page pour voir un nouveau fournisseur (ou en voir disparaître un). Feature #82. Tests : `tests/frontend/ai.test.mjs` (+4) |
| *BUG-044* | [🟡 IMPORTANT] Capacités des modèles IA erronées : aucun modèle Mistral n'est détecté « Vision capable » (et des modèles texte sont annoncés « Embeddings ») | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/model_capabilities.py`, `backend/provider_capabilities.py`, `backend/main.py`, `backend/ai_routes.py` | `curl -H "Authorization: Bearer $MISTRAL_API_KEY" https://api.mistral.ai/v1/models` puis `GET /api/ai/model-capabilities?provider=mistral&model=mistral-small-latest` | Nouveau calque **déclaratif** (`backend/provider_capabilities.py`) : les capacités publiées par le fournisseur (Mistral `capabilities`, OpenRouter `architecture`) sont lues et mises en cache par `GET /api/config/ai-models` ; elles **priment** sur la table statique, qui ne comble plus que les drapeaux non déclarés (et sert de repli hors ligne / sans clé). Table statique corrigée : familles vision Mistral (`ministral`, `magistral`, `mistral-small`, `mistral-medium`, `mistral-vibe-cli`, `labs-leanstral`), `mistral-ocr` = vision sans chat, défaut fournisseur Mistral sans `embeddings`. Tests : `tests/test_provider_capabilities.py`, `tests/test_model_capabilities.py::TestMistralFamilies`, `tests/test_ai_models.py::TestDeclaredCapabilities` | Le panneau de modèle par défaut et la bulle ⓘ n'affichaient **aucun** Mistral vision alors que l'API en déclare 28 ; seul le motif `pixtral` (retiré de l'API) matchait. Effet secondaire corrigé : `mistral-large-latest` / `codestral-latest` étaient annoncés « Embeddings » (défaut fournisseur). La porte vision de l'assistant (`bookslm_routes.py`) accepte désormais les images avec un modèle Mistral vision | | *BUG-044* | [🟡 IMPORTANT] Capacités des modèles IA erronées : aucun modèle Mistral n'est détecté « Vision capable » (et des modèles texte sont annoncés « Embeddings ») | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/model_capabilities.py`, `backend/provider_capabilities.py`, `backend/main.py`, `backend/ai_routes.py` | `curl -H "Authorization: Bearer $MISTRAL_API_KEY" https://api.mistral.ai/v1/models` puis `GET /api/ai/model-capabilities?provider=mistral&model=mistral-small-latest` | Nouveau calque **déclaratif** (`backend/provider_capabilities.py`) : les capacités publiées par le fournisseur (Mistral `capabilities`, OpenRouter `architecture`) sont lues et mises en cache par `GET /api/config/ai-models` ; elles **priment** sur la table statique, qui ne comble plus que les drapeaux non déclarés (et sert de repli hors ligne / sans clé). Table statique corrigée : familles vision Mistral (`ministral`, `magistral`, `mistral-small`, `mistral-medium`, `mistral-vibe-cli`, `labs-leanstral`), `mistral-ocr` = vision sans chat, défaut fournisseur Mistral sans `embeddings`. Tests : `tests/test_provider_capabilities.py`, `tests/test_model_capabilities.py::TestMistralFamilies`, `tests/test_ai_models.py::TestDeclaredCapabilities` | Le panneau de modèle par défaut et la bulle ⓘ n'affichaient **aucun** Mistral vision alors que l'API en déclare 28 ; seul le motif `pixtral` (retiré de l'API) matchait. Effet secondaire corrigé : `mistral-large-latest` / `codestral-latest` étaient annoncés « Embeddings » (défaut fournisseur). La porte vision de l'assistant (`bookslm_routes.py`) accepte désormais les images avec un modèle Mistral vision |
| *BUG-045* | [🟡 IMPORTANT] Éditeur « Editer » : deux barres de défilement superposées sur les documents longs | 🟢 corrigé | P1 | 📱 frontend | Éditeur | `frontend/style.css` | Ouvrir un fichier long (ex. IT/Docker Guide.md), cliquer Editer, mesurer `#editor-body` et `.cm-scroller` | `.editor-body-cm` gardait `overflow:auto` et un `.cm-editor{height:100%}` sous la rangée barre d'outils IA → le corps (toolbar+éditeur) ET le scroller CodeMirror débordaient simultanément. L'override global legacy `.cm-scroller{min-height:100%;overflow-y:auto!important}` aggravait. Passé en flex column : corps `overflow:hidden`, toolbar `flex:0 0 auto`, `.cm-editor` `flex:1 1 auto; height:auto`, seul le scroller défile ; override legacy retiré. Test : `tests/frontend/editor-inline.test.mjs` (+1) ; vérifié Playwright sur l'instance de test (un seul conteneur scrollable). |
| *BUG-046* | [🔴 BLOQUANT] Assistant IA : « Échec de l'action : [object Object] » à l'application d'un ajout de texte au document courant | 🟢 corrigé | P0 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py` | Mode agent : demander d'ajouter du texte au document ouvert puis cliquer « Appliquer » | Trois causes : (1) continuation de confirmation avec `payload:null` → second « Appliquer » sans `message` → 422 ; (2) `new Error(detail)` sur un `detail` tableau d'objets FastAPI → « [object Object] » ; (3) prompt documents/directory sans nom de vault → le modèle inventait `"vault":"test"` → échec silencieux de l'outil. Nouveau `_responseError()` (aplatit tableau/objet), payload porté à la continuation, bloc « Ces documents appartiennent au vault « X » » + consigne outils d'écriture (`build_system_prompt(vault_name=...)`). `SW_VERSION` v20. Tests : `tests/test_bookslm.py::test_vault_name_guidance`, `tests/frontend/ai.test.mjs` (+2). |
| | | | | | | | | | | | | | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -196,6 +198,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-14 | BUG-042 (accents + préfixe vault) | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | **Caractères accentués** : classes de caractères Unicode (`\p{L}\p{N}\p{M}`) pour `PATH_WITH_DIR_RE` et `PATH_NAME_RE` (`_looksLikePath`), comparaison normalisée `NFC` via `_normKey()` → une mention décomposée (`e` + accent combinant, style macOS) correspond à une entrée d'index précomposée, et les liens sans espace mais accentués sont enfin produits. **Préfixe de vault** : `_splitVaultPrefix()` retire un premier segment égal à un vault connu (`TestVault/Recettes/Pizza Maison.md`) ; `_fetchPathsForVault()` interroge l'index de ce vault sans écraser le cache du vault actif ; `_openFileLink`/`_revealPath` reçoivent le vault cible ; repli « retirer le premier segment » si le préfixe est inconnu. Vérifié : tests frontend 62/62 (IA) + 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped, et vérification contre l'instance live (données réelles accentuées/espacées : `Recettes/Préparation.md`, `Recettes/Pâtes carbonara.md`, `Recettes/Pizza Maison.md`) — 8/8 contrôles. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-14 | BUG-042 (accents + préfixe vault) | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | **Caractères accentués** : classes de caractères Unicode (`\p{L}\p{N}\p{M}`) pour `PATH_WITH_DIR_RE` et `PATH_NAME_RE` (`_looksLikePath`), comparaison normalisée `NFC` via `_normKey()` → une mention décomposée (`e` + accent combinant, style macOS) correspond à une entrée d'index précomposée, et les liens sans espace mais accentués sont enfin produits. **Préfixe de vault** : `_splitVaultPrefix()` retire un premier segment égal à un vault connu (`TestVault/Recettes/Pizza Maison.md`) ; `_fetchPathsForVault()` interroge l'index de ce vault sans écraser le cache du vault actif ; `_openFileLink`/`_revealPath` reçoivent le vault cible ; repli « retirer le premier segment » si le préfixe est inconnu. Vérifié : tests frontend 62/62 (IA) + 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped, et vérification contre l'instance live (données réelles accentuées/espacées : `Recettes/Préparation.md`, `Recettes/Pâtes carbonara.md`, `Recettes/Pizza Maison.md`) — 8/8 contrôles. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-14 | BUG-043, #82 | Correction | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-provider-picker.md` | La liste des fournisseurs de la barre latérale de l'assistant suit désormais la configuration du projet : nouveau `refreshAIPickers()` (`ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (host conservé dans la barre de l'assistant, montage par `replaceChildren`), appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; slot préservé même sans fournisseur configuré (le premier fournisseur ajouté s'y monte) ; sélection persistée d'un fournisseur non configuré purgée de `obsigate_ai_picker` (retour au défaut, modèle effacé). Vérifié : tests frontend 66/66 (IA) + 9 suites JSDOM vertes, validate-imports 36 modules, pytest 963 passed / 6 skipped, ruff OK, et vérification en navigateur (Playwright) sur l'instance de test : ajout de `nvidia` → fournisseur visible sans rechargement, retrait → disparition + sélection réinitialisée (`{"provider":null,"model":null}`), un seul montage du picker dans son host. CI Gitea verte (lint, test, security, build, e2e). | | 2026-09-14 | BUG-043, #82 | Correction | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-provider-picker.md` | La liste des fournisseurs de la barre latérale de l'assistant suit désormais la configuration du projet : nouveau `refreshAIPickers()` (`ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (host conservé dans la barre de l'assistant, montage par `replaceChildren`), appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; slot préservé même sans fournisseur configuré (le premier fournisseur ajouté s'y monte) ; sélection persistée d'un fournisseur non configuré purgée de `obsigate_ai_picker` (retour au défaut, modèle effacé). Vérifié : tests frontend 66/66 (IA) + 9 suites JSDOM vertes, validate-imports 36 modules, pytest 963 passed / 6 skipped, ruff OK, et vérification en navigateur (Playwright) sur l'instance de test : ajout de `nvidia` → fournisseur visible sans rechargement, retrait → disparition + sélection réinitialisée (`{"provider":null,"model":null}`), un seul montage du picker dans son host. CI Gitea verte (lint, test, security, build, e2e). |
| 2026-09-15 | BUG-044 | Correction | `backend/provider_capabilities.py` (nouveau), `backend/model_capabilities.py`, `backend/main.py`, `backend/ai_routes.py`, `tests/test_provider_capabilities.py` (nouveau), `tests/test_model_capabilities.py`, `tests/test_ai_models.py`, `docs/features/ai-provider-picker.md`, `CHANGELOG.md` | BUG-044 : les capacités des modèles IA sont désormais lues chez le fournisseur quand il les publie (Mistral `capabilities`, OpenRouter `architecture`, détection par forme du payload), mises en cache par `GET /api/config/ai-models` (aucune requête supplémentaire) et prioritaires sur la table statique qui ne comble plus que les drapeaux non déclarés (repli hors ligne / sans clé). Table statique corrigée : familles vision Mistral (`ministral`, `magistral`, `mistral-small`, `mistral-medium`, `mistral-vibe-cli`, `labs-leanstral`), `mistral-ocr` = vision sans chat, défaut fournisseur Mistral sans `embeddings` (mistral-large / codestral n'étaient plus des « embedders »). Vérifié : diagnostic live avant/après sur l'API Mistral (28 modèles vision déclarés, **0** détectés avant → **28** après, 0 écart dans les deux sens), pytest 1007 passed / 6 skipped, ruff 0 (backend), mypy 0 (68 fichiers), tests frontend (unit 9/9, IA 66/66, validate-imports 37 modules), et vérification sur l'instance de test reconstruite (`obsigate-test`, http://localhost:2020) via les deux endpoints du panneau : `mistral-small/medium-latest`, `ministral-8b-latest`, `magistral-small-latest` → `chat,vision` ; `mistral-ocr-latest` → `vision` seul ; `mistral-large-latest`/`codestral-latest` → `chat` ; `mistral-embed` → `embeddings` seul ; `deepseek-chat` (fournisseur muet) inchangé. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-15 | BUG-044 | Correction | `backend/provider_capabilities.py` (nouveau), `backend/model_capabilities.py`, `backend/main.py`, `backend/ai_routes.py`, `tests/test_provider_capabilities.py` (nouveau), `tests/test_model_capabilities.py`, `tests/test_ai_models.py`, `docs/features/ai-provider-picker.md`, `CHANGELOG.md` | BUG-044 : les capacités des modèles IA sont désormais lues chez le fournisseur quand il les publie (Mistral `capabilities`, OpenRouter `architecture`, détection par forme du payload), mises en cache par `GET /api/config/ai-models` (aucune requête supplémentaire) et prioritaires sur la table statique qui ne comble plus que les drapeaux non déclarés (repli hors ligne / sans clé). Table statique corrigée : familles vision Mistral (`ministral`, `magistral`, `mistral-small`, `mistral-medium`, `mistral-vibe-cli`, `labs-leanstral`), `mistral-ocr` = vision sans chat, défaut fournisseur Mistral sans `embeddings` (mistral-large / codestral n'étaient plus des « embedders »). Vérifié : diagnostic live avant/après sur l'API Mistral (28 modèles vision déclarés, **0** détectés avant → **28** après, 0 écart dans les deux sens), pytest 1007 passed / 6 skipped, ruff 0 (backend), mypy 0 (68 fichiers), tests frontend (unit 9/9, IA 66/66, validate-imports 37 modules), et vérification sur l'instance de test reconstruite (`obsigate-test`, http://localhost:2020) via les deux endpoints du panneau : `mistral-small/medium-latest`, `ministral-8b-latest`, `magistral-small-latest` → `chat,vision` ; `mistral-ocr-latest` → `vision` seul ; `mistral-large-latest`/`codestral-latest` → `chat` ; `mistral-embed` → `embeddings` seul ; `deepseek-chat` (fournisseur muet) inchangé. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-045, BUG-046 | Correction | `frontend/style.css`, `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py`, `frontend/sw.js`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md` | BUG-045 : fin de la double barre de défilement de l'éditeur « Editer » — `#editor-body` en flex column `overflow:hidden`, `.cm-editor` `flex:1 1 auto`, seul le scroller CodeMirror défile ; suppression de l'override global `.cm-scroller` (min-height/overflow-y !important). BUG-046 : « Échec de l'action : [object Object] » à l'application d'un ajout de texte au document courant — (1) `_responseError()` aplatit le `detail` 422 (tableau d'objets FastAPI) en message lisible, (2) la continuation de confirmation hérite du payload d'origine (le second « Appliquer » ne POSTe plus sans `message`), (3) le prompt documents/dossier nomme le vault et enjoint les outils d'écriture d'utiliser ce nom exact (le modèle inventait `"vault":"test"` → échec silencieux). `SW_VERSION` v20. Vérifié : pytest 1038 passed / 6 skipped, ruff 0 (backend), tests frontend IA 84/84, editor-inline 25/25, validate-imports 38 modules ; reproduction et validation Playwright sur l'instance de test (un seul conteneur scrollable après correctif). | 🟢 corrigé (en attente vérif utilisateur) |
--- ---
+27 -4
View File
@@ -2397,6 +2397,27 @@ class BooksLM {
return html; return html;
} }
/**
* Build a readable Error from a non-OK /chat or /agent response. BUG-046:
* a 422 validation error carries `detail` as an ARRAY of objects (FastAPI),
* which `new Error(detail)` stringified to "[object Object]" in the
* "Échec de l'action" toast. Flatten arrays / objects into their messages.
*/
async _responseError(resp) {
let detail = `HTTP ${resp.status}`;
try {
const body = await resp.json();
const d = body && body.detail;
if (typeof d === 'string' && d) detail = d;
else if (Array.isArray(d) && d.length) {
detail = d.map((item) => (item && (item.msg || item.message)) || JSON.stringify(item)).join(' · ');
} else if (d && typeof d === 'object') {
detail = d.msg || d.message || JSON.stringify(d);
}
} catch { /* no json body */ }
return new Error(detail);
}
async _applyConfirmation(msg) { async _applyConfirmation(msg) {
const conf = msg.confirmation; const conf = msg.confirmation;
if (!conf || conf._applying) return; if (!conf || conf._applying) return;
@@ -2413,7 +2434,11 @@ class BooksLM {
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send'); const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true; if (sendBtn) sendBtn.disabled = true;
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: null }; // BUG-046: carry the original request payload over to the continuation.
// When an applied tool failed and the model re-proposed a confirmation,
// the continuation used to have `payload: null`: the second "Appliquer"
// then resumed without `message` → 422 → "[object Object]" toast.
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: msg.payload ? { ...msg.payload } : null };
try { try {
let resp = await this._postChat(payload); let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) { if (resp.status === 401 && AuthManager._authEnabled) {
@@ -2421,9 +2446,7 @@ class BooksLM {
resp = await this._postChat(payload); resp = await this._postChat(payload);
} }
if (!resp.ok) { if (!resp.ok) {
let detail = `HTTP ${resp.status}`; throw await this._responseError(resp);
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
throw new Error(detail);
} }
// The confirmation is resolved: drop the card and show the continuation. // The confirmation is resolved: drop the card and show the continuation.
msg.confirmation = null; msg.confirmation = null;
+14 -7
View File
@@ -3003,8 +3003,16 @@ select {
@keyframes editor-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes editor-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.editor-save-dot.saving { animation: editor-pulse 0.8s infinite; } .editor-save-dot.saving { animation: editor-pulse 0.8s infinite; }
.editor-save-dot.err { color: var(--err); } .editor-save-dot.err .dot { background: var(--err); } .editor-save-dot.err { color: var(--err); } .editor-save-dot.err .dot { background: var(--err); }
.editor-body-cm { flex: 1; overflow: auto; } /* BUG-045: only the CodeMirror scroller scrolls. #editor-body used to keep
.editor-body-cm .cm-editor { height: 100%; } overflow:auto AND host a 100%-tall .cm-editor below the AI toolbar row,
so the body (toolbar + editor) and the scroller both overflowed → two
stacked scrollbars. Flex column: toolbar fixed, editor fills, scroller
scrolls. Combined selector on purpose: `.editor-body` (defined later with
equal specificity) must not re-introduce overflow-y:auto. */
.editor-body.editor-body-cm { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.editor-body.editor-body-cm #ai-toolbar-container { flex: 0 0 auto; }
.editor-body.editor-body-cm .cm-editor { flex: 1 1 auto; height: auto; min-height: 0; overflow: hidden; }
.editor-body.editor-body-cm .fallback-editor { flex: 1 1 auto; height: auto; min-height: 0; resize: none; overflow: auto; }
.editor-body-cm .cm-editor .cm-scroller { font-family: var(--font-mono); font-size: 13.5px; line-height: 1.75; } .editor-body-cm .cm-editor .cm-scroller { font-family: var(--font-mono); font-size: 13.5px; line-height: 1.75; }
.editor-footer { height: 26px; min-height: 26px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; background: var(--bg-secondary); border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); flex-shrink: 0; } .editor-footer { height: 26px; min-height: 26px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; background: var(--bg-secondary); border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
.editor-stat-file { color: var(--text-secondary); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .editor-stat-file { color: var(--text-secondary); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -3555,17 +3563,16 @@ select {
.help-tip-card { .help-tip-card {
padding: 16px; padding: 16px;
} }
/* BUG-045: inside the editor, the CodeMirror scroller is the ONLY scroll
surface. `#editor-body` stays `overflow:hidden` (BUG-020 rule below) and
the scroller fills it — otherwise the body and the scroller scroll
together and render two stacked scrollbars. */
.cm-editor { .cm-editor {
height: auto;
min-height: 100%;
font-size: 0.9rem; font-size: 0.9rem;
} }
.cm-scroller { .cm-scroller {
font-family: "JetBrains Mono", monospace; font-family: "JetBrains Mono", monospace;
overflow-y: auto !important;
overflow-x: auto !important; overflow-x: auto !important;
height: auto;
min-height: 100%;
max-width: 100%; max-width: 100%;
} }
.fallback-editor { .fallback-editor {
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release. * a separate store. Bumping SW_VERSION invalidates it on every release.
*/ */
const SW_VERSION = 'v19'; const SW_VERSION = 'v20';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`;
+28
View File
@@ -191,6 +191,34 @@ async function main() {
panel.remove(); panel.remove();
}); });
// ── 4a-bis. BUG-046: readable errors + payload carry-over on confirmation resume ──
await test("_responseError flattens FastAPI 422 array details (no [object Object])", async () => {
const b = new BooksLM();
const resp422 = {
status: 422,
json: async () => ({ detail: [{ type: "missing", loc: ["body", "message"], msg: "Field required", input: {} }] }),
};
const err422 = await b._responseError(resp422);
assert.equal(err422.message, "Field required", `got "${err422.message}"`);
assert.ok(!err422.message.includes("[object Object]"), "must never stringify to [object Object]");
const respStr = { status: 400, json: async () => ({ detail: "vault invalide" }) };
assert.equal((await b._responseError(respStr)).message, "vault invalide");
const respEmpty = { status: 500, json: async () => { throw new Error("no body"); } };
assert.equal((await b._responseError(respEmpty)).message, "HTTP 500");
});
await test("confirmation resume keeps the payload and formats errors (BUG-046)", async () => {
const { readFileSync } = await import("node:fs");
const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8");
const apply = src.slice(src.indexOf("async _applyConfirmation"));
assert.match(apply, /payload:\s*msg\.payload\s*\?\s*\{[^\n]*\.\.\.msg\.payload[^\n]*\}\s*:\s*null/,
"continuation must inherit the original request payload (no null payload)");
assert.match(apply, /await this\._responseError\(resp\)/,
"resume path must format non-OK responses via _responseError");
});
// ── 4b. Notion-style thread: top-anchored latest post, provider/model tag, action bar ── // ── 4b. Notion-style thread: top-anchored latest post, provider/model tag, action bar ──
await test("thread renders chronologically (user post above its answer)", () => { await test("thread renders chronologically (user post above its answer)", () => {
const b = new BooksLM(); const b = new BooksLM();
+77 -2
View File
@@ -67,6 +67,7 @@ const {
isInlineEditorActive, isInlineEditorActive,
mountEditorInline, mountEditorInline,
unmountEditorInline, unmountEditorInline,
queryEditor,
} = mod; } = mod;
let testCount = 0; let testCount = 0;
@@ -161,6 +162,17 @@ test("unmount without an inline session is a no-op", () => {
assert.equal(container().parentElement, modal()); assert.equal(container().parentElement, modal());
}); });
test("queryEditor follows the container (modal → content area)", () => {
reset();
assert.equal(queryEditor(".editor-brand").textContent, "ObsiGate");
mountEditorInline(area());
assert.ok(queryEditor(".editor-brand"), "brand reachable while mounted inline");
assert.equal(document.getElementById("editor-modal").querySelector(".editor-brand"), null, "brand really left the overlay");
unmountEditorInline();
assert.ok(document.getElementById("editor-modal").querySelector(".editor-brand"), "brand back in the overlay");
reset();
});
test("host area follows the active pane in split view", () => { test("host area follows the active pane in split view", () => {
reset(); reset();
const paneArea = document.createElement("div"); const paneArea = document.createElement("div");
@@ -193,12 +205,37 @@ const forgeSrc = read("frontend", "editor-poc.html");
console.log("Inline edition (#93) — wiring"); console.log("Inline edition (#93) — wiring");
test("utils.js / viewer.js import every editor-inline helper they call", () => {
const helpers = ["getEditorContainer", "getInlineHostArea", "isInlineEditorActive", "mountEditorInline", "unmountEditorInline", "queryEditor"];
const strip = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
for (const [name, raw] of [["utils.js", utilsSrc], ["viewer.js", viewerSrc]]) {
const src = strip(raw);
const marker = "from './editor-inline.js';";
const end = raw.indexOf(marker);
const start = end === -1 ? -1 : raw.lastIndexOf("import {", end);
const imported =
end === -1 || start === -1
? []
: raw
.slice(start + "import {".length, end)
.replace(/\}/g, "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const helper of helpers) {
if (new RegExp(`\\b${helper}\\(`).test(src)) {
assert.ok(imported.includes(helper), `${name} calls ${helper}() without importing it`);
}
}
}
});
test("index.html gives the editor container a stable id", () => { test("index.html gives the editor container a stable id", () => {
assert.match(indexSrc, /<div class="editor-container" id="editor-container">/); assert.match(indexSrc, /<div class="editor-container" id="editor-container">/);
}); });
test("viewer.js mounts the editor inline when rendering is taken over", () => { test("viewer.js mounts the editor inline when rendering is taken over", () => {
assert.match(viewerSrc, /import \{ isInlineEditorActive \} from '\.\/editor-inline\.js';/); assert.ok(viewerSrc.includes("import { isInlineEditorActive, queryEditor } from './editor-inline.js';"));
const guard = viewerSrc.match(/export function renderFile\(data\) \{([\s\S]*?)\n const area = getContentArea\(\);/); const guard = viewerSrc.match(/export function renderFile\(data\) \{([\s\S]*?)\n const area = getContentArea\(\);/);
assert.ok(guard, "renderFile head not found"); assert.ok(guard, "renderFile head not found");
assert.match(guard[1], /if \(isInlineEditorActive\(\)\) detachInlineEditor\(\);/); assert.match(guard[1], /if \(isInlineEditorActive\(\)\) detachInlineEditor\(\);/);
@@ -206,6 +243,8 @@ test("viewer.js mounts the editor inline when rendering is taken over", () => {
test("viewer.js Forge button mounts inline and sizes the iframe accordingly", () => { test("viewer.js Forge button mounts inline and sizes the iframe accordingly", () => {
assert.match(viewerSrc, /var inline = activateInlineEditor\(data\.vault, data\.path\);/); assert.match(viewerSrc, /var inline = activateInlineEditor\(data\.vault, data\.path\);/);
assert.ok(viewerSrc.includes('var brand = queryEditor(".editor-brand");'), "header query must follow the container");
assert.equal(viewerSrc.includes('modal.querySelector(".editor-brand")'), false, "modal-scoped brand query would break inline mode");
assert.match( assert.match(
viewerSrc, viewerSrc,
/iframe\.style\.cssText = "width:100%;height:" \+ \(inline \? "100%" : "82vh"\) \+ ";border:none;display:block;";/, /iframe\.style\.cssText = "width:100%;height:" \+ \(inline \? "100%" : "82vh"\) \+ ";border:none;display:block;";/,
@@ -245,12 +284,32 @@ test("utils.js reloads the displayed document after an AI write", () => {
}); });
test("sync.js forge-close goes through the shared close path", () => { test("sync.js forge-close goes through the shared close path", () => {
assert.match(syncSrc, /import \{ closeEditor \} from '\.\/utils\.js';/); assert.ok(syncSrc.includes("import { closeEditor, reloadExternalWrite } from './utils.js';"), "utils import missing");
const handler = syncSrc.match(/if \(e\.data\.type === 'forge-close'\) \{([\s\S]*?)\n \}/); const handler = syncSrc.match(/if \(e\.data\.type === 'forge-close'\) \{([\s\S]*?)\n \}/);
assert.ok(handler, "forge-close handler not found"); assert.ok(handler, "forge-close handler not found");
assert.match(handler[1], /closeEditor\(\);/); assert.match(handler[1], /closeEditor\(\);/);
}); });
test("sync.js keeps an open edition session alive on external file changes", () => {
const sse = syncSrc.match(/const changed = \(data\.changes \|\| \[\]\)([\s\S]*?)\n \}/);
assert.ok(sse, "SSE index_updated refresh block not found");
assert.ok(sse[1].includes("reloadExternalWrite(state.currentVault, state.currentPath);"), "SSE refresh must not re-render the read view");
assert.ok(!/^\s*openFile\(state\.currentVault/m.test(sse[1]), "openFile must not be called directly here");
});
test("tab activation releases an inline edition session first", () => {
const uiActivate = read("frontend", "js", "ui.js").match(/async activate\(tabId\) \{([\s\S]*?)\n \},/);
assert.ok(uiActivate, "TabManager.activate not found");
assert.match(uiActivate[1], /detachInlineEditor\(\);/);
});
test("pane activation releases an inline edition session first", () => {
const paneSrc = read("frontend", "js", "pane-manager.js");
const paneActivate = paneSrc.match(/async activate\(tabId\) \{([\s\S]*?)\n const cache/);
assert.ok(paneActivate, "PaneTabManager.activate not found");
assert.match(paneActivate[1], /detachInlineEditor\(\);/);
});
test("bookslm.js reports the edited document and the assistant writes", () => { test("bookslm.js reports the edited document and the assistant writes", () => {
assert.match(bookslmSrc, /const editing = this\._editingDocument\(\);/); assert.match(bookslmSrc, /const editing = this\._editingDocument\(\);/);
assert.match(bookslmSrc, /if \(editing\) ctx\.editing = editing;/); assert.match(bookslmSrc, /if \(editing\) ctx\.editing = editing;/);
@@ -282,6 +341,22 @@ test("style.css lets the inline editor fill the content area", () => {
assert.match(fill[1], /max-width: none;/); assert.match(fill[1], /max-width: none;/);
}); });
// ── BUG-045: one scroll surface only in the CodeMirror editor ──
test("style.css: #editor-body scrolls through the CodeMirror scroller only", () => {
const body = cssSrc.match(/\.editor-body\.editor-body-cm \{([^}]*)\}/);
assert.ok(body, "combined .editor-body.editor-body-cm rule not found");
assert.match(body[1], /overflow:\s*hidden;/, "editor body must not scroll itself");
assert.match(body[1], /display:\s*flex;/);
assert.match(body[1], /flex-direction:\s*column;/);
const cm = cssSrc.match(/\.editor-body\.editor-body-cm \.cm-editor \{([^}]*)\}/);
assert.ok(cm, ".cm-editor fill rule not found");
assert.match(cm[1], /flex:\s*1 1 auto;/);
assert.match(cm[1], /height:\s*auto;/, "100% height below the AI toolbar row caused the double scrollbar");
// The global legacy override must be gone: scroller auto-height + forced overflow-y.
assert.ok(!/\.cm-scroller \{[^}]*min-height:\s*100%;/.test(cssSrc),
"global .cm-scroller min-height override reintroduces the double scrollbar");
});
console.log(`\n${testCount - failCount}/${testCount} tests passed`); console.log(`\n${testCount - failCount}/${testCount} tests passed`);
if (failCount > 0) { if (failCount > 0) {
console.error(`${failCount} test(s) failed`); console.error(`${failCount} test(s) failed`);
+17
View File
@@ -271,6 +271,23 @@ class TestBuildSystemPrompt:
assert "⚠️" in prompt or "volumineux" in prompt assert "⚠️" in prompt or "volumineux" in prompt
def test_vault_name_guidance(self):
"""BUG-046: naming the vault steers write tools away from invented names."""
from backend.bookslm import build_system_prompt
context = {
"files": [{"path": "note.md", "title": "N", "content": "x", "type": "markdown"}],
"total_chars": 1,
"file_count": 1,
"directory_tree": "",
}
prompt = build_system_prompt(context, scope="documents", vault_name="TestVault")
assert "TestVault" in prompt
assert "append_to_file" in prompt
assert "jamais un nom inventé" in prompt
# Without a vault name the block stays out (general fallback prompts).
assert "jamais un nom inventé" not in build_system_prompt(context)
# ── Unit tests: caching ──────────────────────────────────────────────── # ── Unit tests: caching ────────────────────────────────────────────────