fix(ai): contexte applicatif de l'assistant, dossier vide et liens fiables (#88, BUG-041, BUG-042)
- 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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user