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:
+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",
|
||||
|
||||
Reference in New Issue
Block a user