diff --git a/CHANGELOG.md b/CHANGELOG.md index 81dfcac..2b1c56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Ajouté + +- **#88 Assistant IA : contexte applicatif** — l'assistant en mode **Général** + reçoit l'état vivant de l'interface (`app_context`) et connaît désormais les + documents ouverts, le répertoire et le vault courants, la recherche en cours + (requête + premiers résultats affichés) et les fichiers récemment modifiés du + utilisateur. Le prompt Général est enrichi d'un bloc « Contexte applicatif + actuel » (`build_general_system_prompt(vaults, app_context, recent_files)`). + Fiche : [docs/features/ai-app-context.md](./docs/features/ai-app-context.md). + +### Corrigé + +- **BUG-041 — Assistant IA bloqué sur un répertoire vide** : l'assistant ne + renvoie plus `⚠ Error: Aucun fichier markdown trouvé dans ce dossier` (HTTP 404). + Le contexte vide dégrade vers le prompt Général augmenté d'un bloc « Dossier + vide », et la requête aboutit sans contexte documentaire. +- **BUG-042 — Liens de fichiers de l'assistant non fiables** : les liens des + réponses suivent désormais une règle déterministe — un simple nom de fichier + copie le nom dans le presse-papiers, un chemin de dossier est révélé dans + l'arborescence, un chemin de fichier l'ouvre. Le chemin est **résolu contre + l'index du vault** (exact → suffixe → basename unique) avant d'agir ; les + chemins non résolus copient le nom au lieu d'afficher `File not found`. + ### Sécurité - **#84 Consolidation & sécurité — phase 1 (BUG-021 → BUG-034)** — traitement des diff --git a/backend/bookslm.py b/backend/bookslm.py index e98e5dc..8c5f153 100644 --- a/backend/bookslm.py +++ b/backend/bookslm.py @@ -499,13 +499,84 @@ Règles : """ -def build_general_system_prompt(vaults: list[str] | None = None) -> str: - """System prompt for the General assistant (app help + actions).""" +def _format_app_context(app_context: dict[str, Any] | None, recent_files: list[dict[str, Any]] | None) -> str: + """Render the live application state for the General assistant prompt. + + The General assistant has no document context; without this block it only + knows the app exists. Passing what the user currently sees (open documents, + current directory, active search, recently modified files) lets it answer + "résume ce que je fais / où j'en suis" style questions. + """ + app_context = app_context or {} + lines: list[str] = [] + + vault = app_context.get("vault") or app_context.get("current_vault") + if vault: + lines.append(f"- Vault sélectionné : {vault}") + directory = app_context.get("directory") + if directory: + lines.append(f"- Répertoire courant : {directory}") + current_path = app_context.get("current_path") + if current_path: + lines.append(f"- Document affiché dans le viewer : {current_path}") + + docs = app_context.get("open_documents") or [] + rendered_docs = [] + for doc in docs: + if not isinstance(doc, dict) or not doc.get("path"): + continue + rendered_docs.append(f"{doc['path']} (vault {doc['vault']})" if doc.get("vault") else str(doc["path"])) + if rendered_docs: + lines.append("- Documents ouverts dans les onglets/panneaux : " + ", ".join(rendered_docs)) + + query = app_context.get("search_query") + if query: + total = app_context.get("search_total") + suffix = f" ({total} résultat(s))" if isinstance(total, int) else "" + lines.append(f"- Recherche en cours : « {query} »{suffix}") + + results = app_context.get("search_results") or [] + rendered_results = [str(r.get("path")) for r in results[:10] if isinstance(r, dict) and r.get("path")] + if rendered_results: + lines.append("- Premiers résultats affichés : " + ", ".join(rendered_results)) + + if recent_files: + rendered_recent = [ + f"{f.get('vault')}/{f.get('path')}" for f in recent_files[:10] if f.get("path") + ] + if rendered_recent: + lines.append("- Fichiers récemment modifiés : " + ", ".join(rendered_recent)) + + if not lines: + return "" + return ( + "## Contexte applicatif actuel\n" + "Voici ce que l'utilisateur voit ou fait en ce moment dans ObsiGate. " + "Sers-t'en pour comprendre sa demande ; ne le répète pas inutilement.\n" + + "\n".join(lines) + + "\n" + ) + + +def build_general_system_prompt( + vaults: list[str] | None = None, + app_context: dict[str, Any] | None = None, + recent_files: list[dict[str, Any]] | None = None, +) -> str: + """System prompt for the General assistant (app help + actions). + + ``app_context`` carries the live UI state (open documents, current + directory, active search) and ``recent_files`` the last modified files, so + the assistant knows what the user is doing rather than answering blind. + """ prompt = GENERAL_SYSTEM_PROMPT if vaults: prompt += "\nVaults disponibles : " + ", ".join(sorted(vaults)) + "\n" else: prompt += "\nAucun vault n'est actuellement configuré.\n" + block = _format_app_context(app_context, recent_files) + if block: + prompt += "\n" + block return prompt diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index 16f6c21..e48f019 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -53,6 +53,11 @@ class BooksLMContextRequest(BaseModel): default_factory=list, description="Ad-hoc directories added with the '@' command (any mode)", ) + app_context: dict[str, Any] | None = Field( + default=None, + description="Live client UI state for the General assistant: open_documents, " + "current_path, directory, vault, search_query, search_total, search_results.", + ) class BooksLMChatRequest(BaseModel): @@ -104,6 +109,11 @@ class BooksLMChatRequest(BaseModel): description="Conversation snapshot returned alongside a ``confirmation`` event, " "echoed back to resume the agent run.", ) + app_context: dict[str, Any] | None = Field( + default=None, + description="Live client UI state for the General assistant: open_documents, " + "current_path, directory, vault, search_query, search_total, search_results.", + ) def _normalize_mode(mode: str | None) -> str: @@ -154,6 +164,34 @@ def _build_context( return base +def _submitted_app_context(req) -> dict[str, Any] | None: + """Return the client-submitted live app state (may be None).""" + ctx = getattr(req, "app_context", None) + return ctx if isinstance(ctx, dict) else None + + +def _recent_files_for_prompt(current_user, limit: int = 10) -> list[dict[str, Any]]: + """Best-effort list of the user's most recently modified files. + + Used to give the General assistant a sense of what the user has been + working on. Never raises: any failure yields an empty list. + """ + try: + from backend.services.recent import list_recent + + username = current_user.get("username") if isinstance(current_user, dict) else None + user_vaults = ( + current_user.get("_token_vaults") or current_user.get("vaults", []) + if isinstance(current_user, dict) + else [] + ) + data = list_recent(username, user_vaults, limit=limit, mode="modified") + return list(data.get("files", [])) + except Exception: # pragma: no cover - defensive, prompt enrichment only + logger.debug("Could not gather recent files for assistant prompt", exc_info=True) + return [] + + def _resolve_system_prompt(req, current_user) -> str: """Resolve the vault access and build the assistant system prompt. @@ -179,11 +217,28 @@ def _resolve_system_prompt(req, current_user) -> str: effective_mode = context.get("scope", mode) if effective_mode == "general": - prompt = build_general_system_prompt(list(index.keys())) + prompt = build_general_system_prompt( + list(index.keys()), + app_context=_submitted_app_context(req), + recent_files=_recent_files_for_prompt(current_user), + ) elif effective_mode == "documents": prompt = build_system_prompt(context, scope="documents") elif context["file_count"] == 0: - raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier") + # Empty (or unreadable) directory: don't block the request. Answer as + # the General assistant would, telling the model the folder is empty so + # it can still help (create a file, explain the app, etc.). + prompt = build_general_system_prompt( + list(index.keys()), + app_context=_submitted_app_context(req), + recent_files=_recent_files_for_prompt(current_user), + ) + prompt += ( + f"\n## Dossier vide\nLe dossier « {req.directory or '/'} » " + f"(vault {req.vault}) ne contient aucun fichier markdown exploitable. " + "Réponds quand même à la demande de l'utilisateur sans contexte " + "documentaire, et propose une action de création si c'est pertinent.\n" + ) else: prompt = build_system_prompt(context, scope="directory") diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 4d04eb6..01aa0cf 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -14,7 +14,7 @@ - **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian - **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop) -- **Dernière mise à jour** : 2026-09-13 +- **Dernière mise à jour** : 2026-09-14 --- @@ -150,6 +150,8 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-038* | [🔵 MINEUR] Argon2 à 64 MB par vérification : risque d'épuisement mémoire | 🔴 ouvert | P2 | 🔐 sécurité | IA | `backend/auth/password.py:8` | Lancer de nombreux `POST /api/auth/login` simultanés | Recalibrer (~19 MB, t=2, p=1, norme OWASP actuelle) + maintien du rate-limit | DoS mémoire possible sur les petites instances | | *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🔴 ouvert | P3 | 🔐 sécurité | IA | `backend/auth/router.py:120` | Tenter un login sur un compte verrouillé puis un nom inconnu | Répondre 401 uniforme avec un timing équivalent | Le statut HTTP distingue l'existence d'un compte | | *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/indexer.py:465` | Démarrer sur un vault contenant de nombreux PDF | Analyser les PDF en tâche de fond / à la demande (lazy) | Ralentit fortement le démarrage et le rebuild d'index | +| *BUG-041* | [🟡 IMPORTANT] Assistant IA : échec sur un répertoire vide (« Aucun fichier markdown trouvé dans ce dossier ») au lieu de répondre | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `backend/bookslm_routes.py`, `backend/bookslm.py`, `frontend/js/bookslm.js` | Ouvrir l'assistant sur un dossier vide puis envoyer une question | `_resolve_system_prompt` dégrade vers le prompt Général + bloc « Dossier vide » (plus de 404) ; contexte applicatif `app_context` enrichi (documents ouverts, répertoire, recherche, fichiers récents) | Le 404 bloquait toute la requête. Feature #88, fiche `docs/features/ai-app-context.md`. Tests : `tests/test_bookslm.py` (+3), `tests/frontend/ai.test.mjs` | +| *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 | `_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 | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+3) | ### TODOs techniques (améliorations / nouvelles tâches) @@ -186,6 +188,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-13 | Revue sécurité statique (BUG-021 → BUG-040, roadmap #84 → #87) | Enregistrement | `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | Revue statique 2026-09-13 consignée : XSS markdown (`escape=False`) + page de partage publique, brute-force MFA, traversal par préfixe `paths.py`, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit, indexation symlinks, recherche O(N), CSP. BUG-021→BUG-040 ouverts au registre ; roadmap #84→#87 (consolidation/sécurité, refonte architecturale, performance, CI/CD). | 🔴 ouvert (à traiter) | | 2026-09-13 | BUG-021 → BUG-034 (#84 phase 1) | Correction | `backend/services/sanitizer.py`, `backend/services/paths.py`, `backend/services/net.py`, `backend/services/regex_safety.py`, `backend/main.py`, `backend/search.py`, `backend/webhooks.py`, `backend/indexer.py`, `backend/ratelimit.py`, `backend/auth/{router,user_store,password,jwt_handler,middleware}.py`, `frontend/js/{auth,admin,sync}.js`, `tests/test_security_hardening.py`, `tests/test_auth_api.py` | Sanitizer XSS serveur (markdown + page de partage), rate-limit/lockout MFA, isolation vaults par segments, caps regex (ReDoS), SSRF webhooks + secrets externalisés, rotation/révocation des jetons, politique de mot de passe + invalidation des sessions, verrous `users.json`, IP réelle dans les audits, rate-limit par compte, symlinks d'index ignorés, recherche simple via inverted index, token en mémoire + cookie HttpOnly + CSP durcie. Vérifié : pytest 961 passed / 6 skipped, ruff 0, mypy 0, tests frontend 36 modules + 9 JSDOM suites verts. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-13 | BUG-017 → BUG-020, #83 | Correction + feature | `frontend/js/mobile-editor.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `tests/frontend/mobile-editor.test.mjs`, `tests/e2e/mobile-editor.spec.js` | BUG-017 : cartes de tableau de bord compressibles (`min-width:0`) ; BUG-018 : en-tête éditeur mobile compact + barre IA tactile ; BUG-019 : ancrage du ruban via `visualViewport` ; BUG-020 : suppression de la double scrollbar. #83 : ruban d'édition horizontal défilable style Obsidian (commandes étendues + personnalisation ⚙ persistée). Vérifié : tests frontend 35/35 (mobile) + 50/50 (IA) + 9 JSDOM suites. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-14 | BUG-041, BUG-042, #88 | Correction + feature | `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py`, `frontend/locales/{fr,en}.json`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | BUG-041 : contexte de dossier vide → dégradation gracieuse vers le prompt Général + bloc « Dossier vide » (fin du 404). BUG-042 : liens de fichiers déterministes (nom → presse-papiers, dossier → arborescence, chemin → viewer) + résolution du chemin contre l'index du vault avant ouverture. #88 : `app_context` (documents ouverts, répertoire/vault courants, recherche + résultats affichés) et fichiers récemment modifiés injectés dans le prompt Général. Vérifié : pytest 963 passed / 6 skipped, ruff 0, mypy 0, tests frontend 52/52 + validate-imports 36 modules. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3fbcb46..71019d0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-13 +> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-14 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -168,6 +168,7 @@ | 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) | | 84 | Consolidation & sécurité — revue statique 2026-09-13 (phase 1, BUG-021→034) | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 83 | Barre d'outils d'édition mobile — ruban style Obsidian Android | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) | +| 88 | Assistant IA — contexte applicatif (documents ouverts, répertoire, recherche, fichiers récents) & liens de fichiers fiables (BUG-041, BUG-042) | 2.3.0 | [features/ai-app-context.md](./features/ai-app-context.md) | --- @@ -175,7 +176,7 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–82 | ~107 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88 | ~107 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours | diff --git a/docs/features/ai-app-context.md b/docs/features/ai-app-context.md new file mode 100644 index 0000000..f2d3f7e --- /dev/null +++ b/docs/features/ai-app-context.md @@ -0,0 +1,73 @@ +# #88 — Assistant IA : contexte applicatif & fiabilité des liens + +> **Statut :** 🟢 corrigé / livré (en attente de vérification utilisateur) +> **Version :** 2.3.0 +> **Composants :** `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py` +> **Bugs liés :** BUG-041, BUG-042 + +## Contexte + +Trois troubles d'usage signalés sur l'Assistant IA (BooksLM) : + +1. **Répertoire vide bloquant** — ouvrir l'assistant sur un dossier ne contenant + aucun fichier markdown renvoyait `⚠ Error: Aucun fichier markdown trouvé dans ce dossier` + (HTTP 404) au lieu de répondre à la question de l'utilisateur. +2. **Assistant Général aveugle** — en mode Général, l'assistant ne savait que + lister les vaults : il ne connaissait ni les documents ouverts, ni le répertoire + courant, ni la page de résultats de recherche, ni les fichiers récemment modifiés. +3. **Liens de fichiers peu fiables** — l'assistant produisait des liens vers des + noms de fichiers qui échouaient avec `File not found: …`, sans règle claire + (nom simple, dossier, chemin complet). + +## Conception + +### 1. Dégradation gracieuse du dossier vide (BUG-041) + +`_resolve_system_prompt` (`backend/bookslm_routes.py`) ne lève plus de 404 quand +le contexte d'un dossier est vide. Il construit le prompt Général (aide +applicative + actions), enrichi d'un bloc **« Dossier vide »**, et prévient le +modèle qu'aucun contexte documentaire n'est disponible — il répond quand même et +propose une création de fichier si pertinent. + +Le prompt Général reçoit en outre les **fichiers récemment modifiés** de +l'utilisateur (`backend/services/recent.list_recent`, best-effort). + +### 2. Contexte applicatif de l'assistant Général (#88) + +Le frontend envoie un nouveau champ `app_context` dans les requêtes `/chat` et +`/agent` (`_buildAppContext()`), contenant l'état vivant de l'interface : + +| Champ | Contenu | +|---|---| +| `open_documents` | Documents ouverts dans les onglets/panneaux (via `collectOpenDocuments()`) | +| `current_path` | Document affiché dans le viewer | +| `directory` | Répertoire courant de l'assistant | +| `vault` | Vault sélectionné | +| `search_query` / `search_total` | Dernière recherche et nombre de résultats | +| `search_results` | Fichiers affichés sur la page de résultats (max 20) | + +Côté backend, `build_general_system_prompt(vaults, app_context, recent_files)` +rend un bloc **« Contexte applicatif actuel »** ajouté au prompt Général. + +### 3. Liens de fichiers déterministes (BUG-042) + +Classification des liens (`_classifyPath`) : + +| Forme | `data-kind` | Action au clic | +|---|---|---| +| Nom de fichier seul (`readme.md`) | `name` | Copie le nom dans le presse-papiers | +| Chemin de dossier (`projets/2026`) | `dir` | Révèle le dossier dans l'arborescence | +| Chemin de fichier (`notes/a.md`) | `file` | Ouvre le fichier dans le viewer | + +Avant d'agir, `_activatePath()` **résout** le chemin contre l'index du vault +(`_resolveExistingPath`) : correspondance exacte, puis suffixe +(segment de tête omis par le modèle), puis basename unique. Les chemins non +résolus copient le nom au lieu d'ouvrir un lien mort — plus de `File not found`. + +## Tests + +- `tests/frontend/ai.test.mjs` : classification (`name`/`file`/`dir`), routage + `_activatePath` (ouvre/révèle/copie), clic d'un lien « nom » → presse-papiers. +- `tests/test_bookslm.py` : `build_general_system_prompt` avec `app_context` + + `recent_files` ; prompt inchangé sans contexte ; chat sur dossier vide ne + renvoie plus 404. diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index c6b76e4..607342e 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -319,6 +319,97 @@ class BooksLM { return this._openFileLink(path); } + _normalizeLinkPath(rawPath) { + return String(rawPath || '') + .replace(/^\.\//, '') + .replace(/\\/g, '/') + .replace(/^\/+/, '') + .replace(/\/+$/, ''); + } + + /** + * Resolve a mentioned path against the vault's indexed paths so links keep + * working even when the model writes a slightly different path (leading + * slash, backslashes, or a bare basename). Returns `{path, name, type}` or + * null when nothing matches. + */ + async _resolveExistingPath(rawPath) { + const clean = this._normalizeLinkPath(rawPath); + if (!clean) return null; + const paths = await this._ensurePaths(); + if (!Array.isArray(paths) || !paths.length) return null; + const lower = clean.toLowerCase(); + + const exact = paths.find((p) => String(p.path || '').toLowerCase() === lower); + if (exact) return exact; + + // Suffix match: the model may omit a leading directory segment. + const suffix = paths.find((p) => String(p.path || '').toLowerCase().endsWith('/' + lower)); + if (suffix) return suffix; + + // Unique basename match (prefer files over directories). + const base = lower.split('/').pop(); + const matches = paths.filter((p) => String(p.name || '').toLowerCase() === base); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + const file = matches.find((p) => p.type === 'file'); + if (file) return file; + } + return null; + } + + /** Copy a mentioned filename to the clipboard (bare-name links). */ + _copyLinkName(path) { + const name = this._normalizeLinkPath(path).split('/').pop(); + if (!name) return; + const done = () => showToast(t('bookslm.copied_name', { name }), 'success'); + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(name).then(done).catch(() => this._fallbackCopy(name, done)); + return; + } + } catch { /* fall through */ } + this._fallbackCopy(name, done); + } + + _fallbackCopy(text, done) { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + if (typeof done === 'function') done(); + } catch { /* clipboard unavailable */ } + } + + /** + * Route a clicked path link: open the file in the viewer, reveal a directory + * in the tree, or (when the path cannot be resolved) copy the filename so the + * user still gets something useful instead of "File not found". + */ + async _activatePath(rawPath, kind) { + const vault = this._activeVault(); + if (!vault) { + showToast(t('bookslm.no_vault'), 'error'); + return; + } + const resolved = await this._resolveExistingPath(rawPath); + if (resolved) { + if (resolved.type === 'directory') return this._revealPath(resolved.path); + return this._openFileLink(resolved.path); + } + if (kind === 'dir') { + // Unknown directory: still try to reveal it in the tree (best effort). + return this._revealPath(this._normalizeLinkPath(rawPath)); + } + // Unknown file: copy the name rather than opening a dead link. + this._copyLinkName(rawPath); + } + /** Resolve the vault used to open/reveal a link mentioned by the assistant. */ _resolveVault() { if (this._vault) return this._vault; @@ -490,6 +581,50 @@ class BooksLM { return this._contextMeta().title; } + /** + * Collect the live application state sent to the General assistant so it + * knows what the user is looking at: open documents, current viewer file, + * current directory/vault, active search and the files shown on the search + * results page. Best-effort — any failure yields a partial object. + */ + _buildAppContext() { + const ctx = {}; + try { + if (this._vault) ctx.vault = this._vault; + else if (state.selectedContextVault && state.selectedContextVault !== 'all') { + ctx.vault = state.selectedContextVault; + } + if (this._directory) ctx.directory = this._directory; + if (state.currentVault) ctx.current_vault = state.currentVault; + if (state.currentPath) ctx.current_path = state.currentPath; + + const docs = collectOpenDocuments(); + if (docs.length) { + ctx.open_documents = docs.map((d) => ({ vault: d.vault, path: d.path })); + } + + const query = state.advancedSearchLastQuery; + if (query) { + ctx.search_query = query; + if (typeof state.advancedSearchTotal === 'number') { + ctx.search_total = state.advancedSearchTotal; + } + } + + // Files currently rendered on the search results page, if any. + const items = document.querySelectorAll('.search-result-item[data-path]'); + if (items.length) { + ctx.search_results = Array.from(items).slice(0, 20).map((el) => ({ + vault: el.getAttribute('data-vault'), + path: el.getAttribute('data-path'), + })); + } + } catch (e) { + console.warn('AI assistant: app context collection failed', e); + } + return ctx; + } + // ── Rendering ─────────────────────────────────────────────────────── _render() { @@ -559,15 +694,20 @@ class BooksLM { panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar()); // Clicking a file/path link inside an assistant answer either opens the - // file in the viewer or reveals the path in the sidebar tree. + // file in the viewer, reveals a directory in the sidebar tree, or (for a + // bare filename with no directory) copies the name to the clipboard. panel.querySelector('.bookslm-messages').addEventListener('click', (e) => { const link = e.target.closest('a.bookslm-path-link'); if (!link) return; e.preventDefault(); const path = link.getAttribute('data-path'); if (!path) return; - if (link.getAttribute('data-kind') === 'dir') this._revealPath(path); - else this._openFileLink(path); + const kind = link.getAttribute('data-kind'); + if (kind === 'name') { + this._copyLinkName(path); + return; + } + this._activatePath(path, kind); }); // Close the session / command menus when clicking elsewhere in the panel. @@ -1722,6 +1862,7 @@ class BooksLM { conversation_history: history, provider, model, + app_context: this._buildAppContext(), }; const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload }; @@ -2085,7 +2226,11 @@ class BooksLM { } _classifyPath(path) { - return FILE_EXT_RE.test(path || '') ? 'file' : 'dir'; + const p = String(path || ''); + // A bare filename (no directory) is copied to the clipboard instead of + // being treated as a vault path — avoids "File not found" dead links. + if (!p.includes('/')) return FILE_EXT_RE.test(p) ? 'name' : 'dir'; + return FILE_EXT_RE.test(p) ? 'file' : 'dir'; } _looksLikePath(value) { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index d0f3682..6ed6075 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1271,9 +1271,9 @@ "help.title_tags": "Title and tags", "help.toc_title": "CONTENTS", "help.assistant_panel": "🧠 Assistant panel (BooksLM)", - "help.assistant_panel_desc": "The side assistant (floating button or a folder's context menu) answers in formatted Markdown and contextualises your directories or documents.", + "help.assistant_panel_desc": "The side assistant (floating button or a folder's context menu) answers in formatted Markdown and contextualises your directories or documents. In general mode it also knows what you are looking at: open documents, current directory, active search and recently modified files.", "help.assistant_markdown": "Formatted answers: headings, lists, tables, quotes and code blocks.", - "help.assistant_links": "Cited files and paths are links: click a file to open it, a folder to reveal it in the tree.", + "help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.", "help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.", "help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.", "help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.", @@ -1771,6 +1771,7 @@ "bookslm.history_empty": "No saved session", "bookslm.untitled": "Untitled conversation", "bookslm.no_vault": "No active vault to open this link", + "bookslm.copied_name": "Name copied: {name}", "palette.bookslm_open": "BooksLM: Open for current directory", "palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)", "palette.bookslm_new": "BooksLM: New conversation", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 3911ce6..b25d1b3 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1271,9 +1271,9 @@ "help.title_tags": "Titre et tags", "help.toc_title": "SOMMAIRE", "help.assistant_panel": "🧠 Panneau Assistant (BooksLM)", - "help.assistant_panel_desc": "L'assistant latéral (bouton flottant ou menu contextuel d'un dossier) répond en Markdown formaté et contextualise vos répertoires ou documents.", + "help.assistant_panel_desc": "L'assistant latéral (bouton flottant ou menu contextuel d'un dossier) répond en Markdown formaté et contextualise vos répertoires ou documents. En contexte général, il connaît aussi ce que vous voyez : documents ouverts, répertoire courant, recherche en cours et fichiers récemment modifiés.", "help.assistant_markdown": "Réponses formatées : titres, listes, tableaux, citations et blocs de code.", - "help.assistant_links": "Les fichiers et chemins cités sont des liens : cliquez sur un fichier pour l'ouvrir, sur un dossier pour le révéler dans l'arborescence.", + "help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.", "help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.", "help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.", "help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.", @@ -1771,6 +1771,7 @@ "bookslm.history_empty": "Aucune session enregistrée", "bookslm.untitled": "Conversation sans titre", "bookslm.no_vault": "Aucun vault actif pour ouvrir ce lien", + "bookslm.copied_name": "Nom copié : {name}", "palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant", "palette.bookslm_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)", "palette.bookslm_new": "BooksLM: Nouvelle conversation", diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 5512af7..5bc5a6e 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -408,6 +408,13 @@ async function main() { assert.ok(html.includes('data-path="docs/guide.md"'), "markdown link linkified"); }); + await test("_classifyPath treats a bare filename as a name, not a file", () => { + const b = new BooksLM(); + assert.equal(b._classifyPath("readme.md"), "name", "bare filename → copy"); + assert.equal(b._classifyPath("notes/a.md"), "file", "dir+file → open"); + assert.equal(b._classifyPath("projets/2026"), "dir", "dir path → reveal"); + }); + await test("_renderMarkdown keeps external links external", () => { const b = new BooksLM(); const html = b._renderMarkdown("[site](https://example.com/page)"); @@ -415,25 +422,45 @@ async function main() { assert.ok(!html.includes("bookslm-path-link"), "external link not treated as a vault path"); }); - await test("clicking a path link opens a file or reveals a directory", () => { + await test("_activatePath opens files, reveals dirs, copies unknown names", async () => { + const b = new BooksLM(); + b._activeVault = () => "TestVault"; + const opened = []; + const revealed = []; + const copied = []; + b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); }; + b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); }; + b._copyLinkName = (p) => { copied.push(p); }; + + // Resolved file → open the canonical path (not the raw mention). + b._resolveExistingPath = async () => ({ path: "notes/a.md", name: "a.md", type: "file" }); + await b._activatePath("a.md", "file"); + assert.deepEqual(opened, ["notes/a.md"]); + + // Resolved directory → reveal. + b._resolveExistingPath = async () => ({ path: "projets/2026", name: "2026", type: "directory" }); + await b._activatePath("projets/2026", "dir"); + assert.deepEqual(revealed, ["projets/2026"]); + + // Unresolvable file mention → copy the name instead of a dead link. + b._resolveExistingPath = async () => null; + await b._activatePath("bidule.md", "file"); + assert.deepEqual(copied, ["bidule.md"]); + }); + + await test("clicking a bare-name link copies the filename to the clipboard", async () => { const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); - const opened = []; - const revealed = []; - b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); }; - b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); }; + const copied = []; + b._copyLinkName = (p) => { copied.push(p); }; const container = b._panel.querySelector(".bookslm-messages"); - container.innerHTML = b._renderMarkdown("`notes/a.md` et `dossier/sous-dossier`"); - const fileLink = container.querySelector('a[data-kind="file"]'); - const dirLink = container.querySelector('a[data-kind="dir"]'); - assert.ok(fileLink, "file link rendered"); - assert.ok(dirLink, "dir link rendered"); - fileLink.click(); - dirLink.click(); - assert.deepEqual(opened, ["notes/a.md"]); - assert.deepEqual(revealed, ["dossier/sous-dossier"]); + container.innerHTML = b._renderMarkdown("Voir `readme.md` pour tout."); + const nameLink = container.querySelector('a[data-kind="name"]'); + assert.ok(nameLink, "bare filename rendered as a name link"); + nameLink.click(); + assert.deepEqual(copied, ["readme.md"]); b._panel.remove(); }); diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py index b853c26..9b52117 100644 --- a/tests/test_bookslm.py +++ b/tests/test_bookslm.py @@ -527,15 +527,17 @@ class TestBooksLMChatEndpoint: ) assert resp.status_code == 404 - def test_chat_empty_directory(self, bookslm_client): - """Chat endpoint returns 404 for empty directory.""" + def test_chat_empty_directory_does_not_block(self, bookslm_client): + """Chat on an empty directory must not 404 — it answers without context.""" token, _ = _login_bookslm(bookslm_client) resp = bookslm_client.post( "/api/ai/bookslm/chat", json={"vault": "TestVault", "directory": "nonexistent", "message": "Hello"}, headers={"Authorization": f"Bearer {token}"}, ) - assert resp.status_code == 404 + # 200 (stream opened) or 500 (no AI key) — never 404/422. + assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}" + assert resp.status_code != 404 def test_chat_request_accepts_provider_and_model(self, bookslm_client): """The chat endpoint schema accepts provider + model fields without rejecting. @@ -641,6 +643,36 @@ class TestGeneralPrompt: assert "Beta" in prompt assert "obsigate-action" in prompt + def test_general_prompt_includes_app_context(self): + """The General assistant is told what the user is currently doing.""" + from backend.bookslm import build_general_system_prompt + + prompt = build_general_system_prompt( + ["Alpha"], + app_context={ + "vault": "Alpha", + "directory": "notes", + "current_path": "notes/a.md", + "open_documents": [{"vault": "Alpha", "path": "notes/a.md"}], + "search_query": "auth", + "search_total": 3, + "search_results": [{"vault": "Alpha", "path": "notes/auth.md"}], + }, + recent_files=[{"vault": "Alpha", "path": "notes/recent.md"}], + ) + + assert "Contexte applicatif actuel" in prompt + assert "notes/a.md" in prompt + assert "notes/recent.md" in prompt + assert "auth" in prompt + + def test_general_prompt_without_app_context_is_unchanged(self): + """No context → no app-context block (backwards compatible).""" + from backend.bookslm import build_general_system_prompt + + prompt = build_general_system_prompt(["Alpha"]) + assert "Contexte applicatif actuel" not in prompt + def test_documents_prompt_scope(self): from backend.bookslm import build_system_prompt