fix(ai): contexte applicatif de l'assistant, dossier vide et liens fiables (#88, BUG-041, BUG-042)
CI / lint (push) Successful in 1m16s
CI / security (push) Successful in 50s
CI / test (push) Successful in 3m0s
CI / build (push) Successful in 47s
CI / e2e (push) Successful in 10m58s

- BUG-041 : le contexte d'un dossier vide ne renvoie plus 404 ; il degrade
  vers le prompt General enrichi d'un bloc « Dossier vide ».
- BUG-042 : liens de fichiers deterministes (nom -> presse-papiers,
  dossier -> arborescence, chemin -> viewer) et resolution du chemin contre
  l'index du vault (exact -> suffixe -> basename) avant ouverture, fin des
  'File not found'.
- #88 : app_context (documents ouverts, repertoire/vault courants, recherche
  + resultats affiches) et fichiers recemment modifies injectes dans le
  prompt General.

Tests : pytest 963 passed / 6 skipped, ruff 0, mypy 0, frontend 52/52 +
validate-imports 36 modules.
This commit is contained in:
2026-09-14 11:28:30 -04:00
parent 05ed36240b
commit 314e37c433
11 changed files with 464 additions and 32 deletions
+23
View File
@@ -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
+73 -2
View File
@@ -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
+57 -2
View File
@@ -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")
+4 -1
View File
@@ -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) |
---
+3 -2
View File
@@ -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 |
+73
View File
@@ -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.
+149 -4
View File
@@ -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) {
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+41 -14
View File
@@ -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();
});
+35 -3
View File
@@ -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