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
+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",