diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d1837..f2f9885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). persistés dans `data/config.json`, rechargement à chaud dans `backend/ai.py`, sélecteurs « Fournisseur par défaut » / « Modèle par défaut » dans `#cfg-ai`, i18n FR/EN. +- **#80 Assistant IA — Rendu Markdown, liens fichiers/paths & sessions** — réponses de l'assistant + rendues en **Markdown formaté** (titres, listes, tableaux, citations, code, emphase) via un + renderer auto-contenu ; les **fichiers et chemins** mentionnés deviennent des liens cliquables + (clic fichier → ouverture dans le viewer, clic répertoire → révélation/surlignage dans + l'arborescence, outils `open_file`/`reveal_in_tree` côté in-app) ; **gestion des sessions** par + contexte (historique consultable, rechargeable, supprimable) avec migration de l'ancienne + conversation unique. Tests : `tests/frontend/ai.test.mjs`. + +### Modifié + +- **Assistant IA (`frontend/js/bookslm.js`)** — l'ancien rendu Markdown minimal est remplacé par un + renderer bloc/inline complet ; l'en-tête expose un bouton « Historique des sessions » ; les badges + de sources ouvrent désormais réellement le fichier (événement `obsigate:open-file`). + ### Corrigé - **Typage backend (mypy) — 33 erreurs corrigées** (`backend/main.py`, `indexer.py`, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index be4b929..23695c0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,6 +54,16 @@ - [ ] **F.** Durcissement (rate limiting, redaction secrets, OpenAPI + guide MCP, E2E) - **Documentation :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) (architecture, catalogue d'outils, sécurité, phases). +### 80. Assistant IA — Rendu Markdown, liens fichiers/paths & sessions + +- **Effort :** 1-2 jours | **Impact :** 🟡 +- **Statut :** livré (en attente de vérification utilisateur). Détail complet : [features/ai-assistant-ux.md](./features/ai-assistant-ux.md) +- **Description :** Réponses de l'assistant rendues en **Markdown formaté** (titres, listes, tableaux, + citations, code) ; les **fichiers et chemins** mentionnés deviennent des liens cliquables (ouvrir + le fichier / révéler le répertoire dans l'arborescence) ; **gestion des sessions** (historique + consultable, rechargeable et supprimable par contexte). +- **Reste à faire :** validation utilisateur. + --- ## ⚪ Backlog — Priorité 3 (P3) @@ -162,6 +172,7 @@ | 76 | BooksLM — Console AI contextuelle par répertoire | 2.2.0 | [features/bookslm.md](./features/bookslm.md) | | 78 | Éditeur Excalidraw | 2.2.0 | [features/excalidraw.md](./features/excalidraw.md) | | 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) | +| 80 | Assistant IA — Rendu Markdown, liens fichiers/paths & sessions | 🔵 en cours | [features/ai-assistant-ux.md](./features/ai-assistant-ux.md) | --- @@ -171,7 +182,7 @@ |---|---|---| | ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours | -| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + G livrés) | ~15-22 jours | +| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours | | ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours | | **Total restant** | **6 items + finitions** | **~27-39 jours** | diff --git a/docs/features/ai-assistant-ux.md b/docs/features/ai-assistant-ux.md new file mode 100644 index 0000000..cac5fac --- /dev/null +++ b/docs/features/ai-assistant-ux.md @@ -0,0 +1,55 @@ +# #80 — Assistant IA — Rendu Markdown, liens de fichiers/paths & sessions + +> **Statut :** 🔵 Livré (en attente de vérification utilisateur) +> **Effort :** 1-2 jours | **Impact :** 🟡 +> **Références :** [Roadmap](../ROADMAP.md) · [BooksLM #76](./bookslm.md) · [Outils IA #79](./ai-tools-mcp.md) · [Changelog](../../CHANGELOG.md) + +- **Description :** Trois améliorations d'expérience de l'assistant IA (`frontend/js/bookslm.js`) : + 1. **Rendu Markdown** des réponses de l'assistant (titres, listes, tableaux, citations, blocs de + code, emphase) au lieu d'un rendu texte approximatif. + 2. **Liens cliquables** : les noms de fichiers et chemins mentionnés par l'assistant deviennent + des liens — clic sur un **fichier** → ouverture dans le viewer ; clic sur un **répertoire** → + révélation et surlignage dans l'arborescence (outil `reveal_in_tree`). + 3. **Gestion des sessions** : chaque contexte (répertoire / documents / général) conserve un + **historique de sessions** consultable, rechargeable et supprimable. + +## A. Rendu Markdown — ✅ livré + - [x] **A1.** Renderer auto-contenu (aucune dépendance externe) : titres `#`→`######`, listes + ordonnées/non ordonnées (imbriquées), listes de tâches, tableaux, citations `>`, règles + horizontales, blocs de code clôturés avec langage, code inline, gras/italique/barré, liens. + - [x] **A2.** Échappement HTML systématique avant génération de balises (anti-injection) ; + seules les balises produites par le renderer sont injectées. + - [x] **A3.** Styles dédiés dans `frontend/style.css` (`.bookslm-bubble.assistant ...`). + +## B. Liens fichiers / chemins — ✅ livré + - [x] **B1.** Détection des mentions : code inline (`` `notes/a.md` ``), liens Markdown + `[libellé](chemin)` et chemins nus `dossier/fichier.ext`. + - [x] **B2.** Classification `file` (extension connue) vs `dir` (le reste) via `_classifyPath()`. + - [x] **B3.** Clic fichier → `Viewer.openFile()` (événement `obsigate:open-file` également émis) puis + repositionnement de l'indicateur dans l'arborescence ; clic répertoire → `focusPathInSidebar()` + (`obsigate:reveal-in-tree`). + - [x] **B4.** Résolution du vault : vault du contexte, sinon premier document ouvert, sinon + `state.currentVault`. Lien externe `http(s)` conservé tel quel. + +## C. Gestion des sessions — ✅ livré + - [x] **C1.** Stockage `bookslm-sessions-{mode}-{contexte}` (localStorage) : liste de sessions + `{id, title, createdAt, updatedAt, messages}`. + - [x] **C2.** Migration automatique de l'ancienne clé mono-conversation + `bookslm-history-{mode}-{contexte}`. + - [x] **C3.** Bouton « Historique » dans l'en-tête → menu listant les sessions (titre dérivé du + 1er message utilisateur + date), sélection pour recharger, bouton de suppression. + - [x] **C4.** `Nouvelle conversation` crée une session ; `_saveHistory()` met à jour la session + courante (upsert) et son titre. + +## D. Tests & documentation — ✅ livré + - [x] **D1.** `tests/frontend/ai.test.mjs` : rendu Markdown, échappement HTML, liens fichier/dossier, + clic sur lien, persistance/rechargement/suppression de session, menu d'historique, migration. + - [x] **D2.** i18n FR/EN (`bookslm.session_history`, `bookslm.session_delete`, `bookslm.history_empty`, + `bookslm.untitled`, `bookslm.no_vault`). + - [x] **D3.** CHANGELOG + Roadmap. + +## E. Points d'attention + - Le renderer est volontairement **autonome** (pas de CDN) pour rester disponible hors-ligne et + compatible PWA. Il ne couvre pas les extensions Markdown d'Obsidian (wikilinks, callouts). + - Les liens de chemin utilisent `data-kind` pour distinguer fichier/répertoire ; le vault est + résolu au moment du clic (pas stocké dans le HTML). diff --git a/docs/features/ai-tools-mcp.md b/docs/features/ai-tools-mcp.md index 6ca8cc8..3260bfc 100644 --- a/docs/features/ai-tools-mcp.md +++ b/docs/features/ai-tools-mcp.md @@ -1,6 +1,6 @@ # #79 — Assistant IA — Outils (function calling) & serveur MCP -> **Statut :** 🔵 En cours — Phase 0 + B1/B2/B3/B7 + G livrés (2026-09-11) +> **Statut :** 🔵 En cours — Phase 0 + B1/B2/B3/B6/B7 + G livrés (2026-09-11) > **Effort :** 10-15 jours | **Impact :** 🟡 > **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md) @@ -20,7 +20,7 @@ - [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend - [ ] **B4.** SSE réellement streaming (corriger `bookslm_routes.py` — le message final reste envoyé en un seul événement) - [ ] **B5.** Confirmations UI : outils `read` auto, outils `write` via carte Apply (`bookslm.js:515`), aperçu diff pour `edit_file` - - [ ] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) + - [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md)) - [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`) ## C. Catalogue d'outils — lecture & recherche (1-2 jours) diff --git a/docs/features/bookslm.md b/docs/features/bookslm.md index 6f1441d..264db3c 100644 --- a/docs/features/bookslm.md +++ b/docs/features/bookslm.md @@ -4,6 +4,9 @@ > **Effort :** 5-6 jours (réalisé) | **Impact :** 🟡 > **Références :** [Roadmap](../ROADMAP.md) · [Changelog — 2.2.0](../../CHANGELOG.md) +> **Extension (#80, 2026-09-11) :** rendu Markdown complet des réponses, liens cliquables +> fichiers/chemins et gestion multi-sessions — voir [ai-assistant-ux.md](./ai-assistant-ux.md). +> > **Extension (2026-09-11) :** l'assistant AI est désormais **multi-contexte** — Répertoire > (mode historique), Documents (documents ouverts, via le bouton flottant) et Général (aucun > document ouvert : aide sur l'app + création de fichiers/dossiers par cartes d'action). Header diff --git a/frontend/index.html b/frontend/index.html index 5fcc0c6..d67b3c3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4181,6 +4181,29 @@ Pour changer de provider par défaut : AI_DEFAULT_PROVIDER=openrouter

+ +

🧠 Panneau Assistant (BooksLM)

+

+ L'assistant latéral (bouton flottant ou menu contextuel d'un + dossier) répond en Markdown formaté et contextualise vos + répertoires ou documents. +

+ diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 667c890..1a11b8e 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -9,6 +9,7 @@ import { buildAIPickerUI } from './ai.js'; import { safeCreateIcons } from './utils.js'; import { api, AuthManager } from './auth.js'; import { showToast } from './ui.js'; +import { state } from './state.js'; export const MODE = Object.freeze({ DIRECTORY: 'directory', @@ -16,6 +17,13 @@ export const MODE = Object.freeze({ GENERAL: 'general', }); +// File extensions recognised when turning a mention into a clickable link. +// Anything else that looks like a path is treated as a directory (revealed +// in the sidebar tree instead of being opened in the viewer). +const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i; +// A path-like token: optional `dir/` segments followed by a final segment. +const PATH_WITH_DIR_RE = /(^|[\s(>"'])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8})/g; + /** * Gather the files currently open across the singleton TabManager and every * split-view pane (window globals avoid a circular import with ui.js). @@ -63,6 +71,9 @@ class BooksLM { this._abortCtrl = null; this._panel = null; this._isFullscreen = false; + this._sessions = []; + this._currentSessionId = null; + this._pendingNewSession = false; } // ── Public API ────────────────────────────────────────────────────── @@ -122,6 +133,8 @@ class BooksLM { this._updateStatus(); this._renderMessages(); this._showSuggestions(); + this._closeHistoryMenu(); + this._renderHistoryMenu(); requestAnimationFrame(() => { this._panel.classList.add('open'); @@ -194,17 +207,27 @@ class BooksLM { } newConversation() { - this._messages = []; - this._saveHistory(); - if (this._panel) { - this._renderMessages(); - this._showSuggestions(); + // Called before the panel exists (command palette): defer the fresh + // session until the assistant opens for its actual context. + if (!this._panel) { + this._pendingNewSession = true; + this._messages = []; + return; } + const session = this._createSession([]); + this._sessions.unshift(session); + this._currentSessionId = session.id; + this._messages = session.messages; + this._persistSessions(); + this._renderMessages(); + this._showSuggestions(); + this._renderHistoryMenu(); } exportConversation() { if (!this._messages.length) return; - const label = this._contextTitle() || 'assistant'; + const current = this._currentSession(); + const label = (current && current.title) || this._contextTitle() || 'assistant'; let md = `# ${label}\n\n`; for (const msg of this._messages) { const role = msg.role === 'user' ? '**You**' : '**AI**'; @@ -219,9 +242,60 @@ class BooksLM { } openFile(path) { - window.dispatchEvent(new CustomEvent('obsigate:open-file', { - detail: { vault: this._vault, path }, - })); + return this._openFileLink(path); + } + + /** Resolve the vault used to open/reveal a link mentioned by the assistant. */ + _resolveVault() { + if (this._vault) return this._vault; + try { + const docs = collectOpenDocuments(); + if (docs.length) return docs[0].vault; + } catch { /* not ready */ } + return state.currentVault || null; + } + + /** Open a file mentioned in an assistant answer in the main viewer. */ + async _openFileLink(path) { + const vault = this._resolveVault(); + if (!vault || !path) { + showToast(t('bookslm.no_vault'), 'error'); + return; + } + window.dispatchEvent(new CustomEvent('obsigate:open-file', { detail: { vault, path } })); + try { + const m = await import('./viewer.js'); + if (m && typeof m.openFile === 'function') await m.openFile(vault, path); + } catch (e) { + console.warn('AI assistant: open file link failed', e); + } + // Reposition the tree indicator on the opened file (best-effort). + try { + const s = await import('./sidebar.js'); + if (s && typeof s.focusPathInSidebar === 'function') { + await s.focusPathInSidebar(vault, path, { alignToTop: true }); + } + } catch (e) { + console.warn('AI assistant: reveal file in tree failed', e); + } + } + + /** Reveal a directory (or file) path in the sidebar tree. */ + async _revealPath(path) { + const vault = this._resolveVault(); + if (!vault || !path) { + showToast(t('bookslm.no_vault'), 'error'); + return; + } + window.dispatchEvent(new CustomEvent('obsigate:reveal-in-tree', { detail: { vault, path } })); + try { + const m = await import('./sidebar.js'); + if (m && typeof m.focusPathInSidebar === 'function') { + await m.focusPathInSidebar(vault, path, { alignToTop: true, expandTarget: true }); + } + } catch (e) { + console.warn('AI assistant: reveal path failed', e); + } } // ── Context ───────────────────────────────────────────────────────── @@ -302,12 +376,14 @@ class BooksLM {
+
+
${t('ai.model_settings')} @@ -333,8 +409,31 @@ class BooksLM { panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close()); panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation()); + panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => { + e.stopPropagation(); + this._toggleHistoryMenu(); + }); panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation()); 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. + 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); + }); + + // Close the session menu when clicking elsewhere in the panel. + panel.addEventListener('click', (e) => { + if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) { + this._closeHistoryMenu(); + } + }); panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => { this._isFullscreen = !this._isFullscreen; panel.classList.toggle('fullscreen', this._isFullscreen); @@ -710,49 +809,491 @@ class BooksLM { this._saveHistory(); } - // ── Markdown (lightweight) ────────────────────────────────────────── + // ── Markdown (self-contained renderer) ────────────────────────────── + // + // Renders the assistant answer as formatted HTML (headings, lists, tables, + // blockquotes, code, emphasis) without any external dependency. All model + // text is HTML-escaped before markup is produced, and file/path mentions + // become clickable links handled by the delegated listener in `_render()`. _renderMarkdown(text) { if (!text) return ''; - // Escape HTML first so model output cannot inject markup. - let html = text + const lines = String(text).replace(/\r\n?/g, '\n').split('\n'); + const out = []; + const para = []; + let i = 0; + + const flushPara = () => { + if (!para.length) return; + out.push(`

${this._renderInline(para.join('\n'))}

`); + para.length = 0; + }; + + while (i < lines.length) { + const line = lines[i]; + + // Fenced code block. + const fence = line.match(/^\s*```\s*([\w+#-]*)\s*$/); + if (fence) { + flushPara(); + const lang = fence[1] || ''; + const code = []; + i++; + while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { + code.push(lines[i]); + i++; + } + i++; // closing fence + out.push(this._renderCodeBlock(lang, code.join('\n'))); + continue; + } + + // ATX heading. + const heading = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/); + if (heading) { + flushPara(); + const level = heading[1].length; + out.push(`${this._renderInline(heading[2])}`); + i++; + continue; + } + + // Horizontal rule. + if (/^\s*(?:[-*_])\s*(?:[-*_]\s*){2,}$/.test(line)) { + flushPara(); + out.push('
'); + i++; + continue; + } + + // Blockquote (may contain any block content). + if (/^\s*>\s?/.test(line)) { + flushPara(); + const quote = []; + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + quote.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + out.push(`
${this._renderMarkdown(quote.join('\n'))}
`); + continue; + } + + // Table. + if (line.includes('|') && i + 1 < lines.length && this._isTableSeparator(lines[i + 1])) { + flushPara(); + const table = this._renderTable(lines, i); + out.push(table.html); + i = table.next; + continue; + } + + // List. + if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) { + flushPara(); + const list = this._renderList(lines, i); + out.push(list.html); + i = list.next; + continue; + } + + if (!line.trim()) { + flushPara(); + i++; + continue; + } + + para.push(line); + i++; + } + + flushPara(); + return out.join('\n'); + } + + /** Render the inline (span-level) markup of a single line or paragraph. */ + _renderInline(text) { + if (text == null) return ''; + const stash = []; + const put = (html) => { + const idx = stash.length; + stash.push(html); + return `\u0000${idx}\u0000`; + }; + + let s = this._escapeHtml(String(text)); + + // Inline code — a path wrapped in backticks becomes a clickable link. + s = s.replace(/`([^`\n]+)`/g, (_m, code) => { + const raw = this._decodeEntities(code); + if (this._looksLikePath(raw)) return put(`${this._pathLink(code, raw)}`); + return put(`${code}`); + }); + + // Explicit markdown links: [label](target). + s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, target) => + put(this._renderMdLink(label, target))); + + // Bare `dir/file.ext` mentions. + s = s.replace(PATH_WITH_DIR_RE, (_m, pre, path) => + `${pre}${put(this._pathLink(path, this._decodeEntities(path)))}`); + + // Emphasis. + s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1'); + s = s.replace(/__([^_\n]+)__/g, '$1'); + s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); + s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1$2'); + s = s.replace(/~~([^~\n]+)~~/g, '$1'); + + // Preserve single line breaks (chat answers often rely on them). + s = s.replace(/\n/g, '
'); + + // Restore stashed fragments. + s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => stash[Number(idx)] || ''); + return s; + } + + _renderCodeBlock(lang, code) { + const cls = lang ? ` class="language-${this._escapeAttr(lang)}"` : ''; + return `
${this._escapeHtml(code)}
`; + } + + _isTableSeparator(line) { + if (!line || !line.includes('|') || !line.includes('-')) return false; + return line.replace(/[|\s:-]/g, '') === ''; + } + + _renderTable(lines, start) { + const splitRow = (line) => + line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim()); + const header = splitRow(lines[start]); + const rows = []; + let i = start + 2; + while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { + rows.push(splitRow(lines[i])); + i++; + } + const thead = header.map((c) => `${this._renderInline(c)}`).join(''); + const tbody = rows + .map((r) => `${r.map((c) => `${this._renderInline(c)}`).join('')}`) + .join(''); + return { html: `${thead}${tbody}
`, next: i }; + } + + _renderList(lines, start) { + const first = lines[start].match(/^(\s*)([-*+]|\d+\.)\s+/); + const baseIndent = first ? first[1].length : 0; + const ordered = first ? /\d+\./.test(first[2]) : false; + const items = []; + let i = start; + + while (i < lines.length) { + const line = lines[i]; + if (!line.trim()) break; + const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/); + if (!m) break; + const indent = m[1].length; + if (indent < baseIndent) break; + if (indent > baseIndent) { + const nested = this._renderList(lines, i); + if (items.length) items[items.length - 1].children.push(nested.html); + i = nested.next; + continue; + } + let content = m[3]; + let checkbox = ''; + const task = content.match(/^\[([ xX])\]\s+(.*)$/); + if (task) { + checkbox = ` `; + content = task[2]; + } + items.push({ html: checkbox + this._renderInline(content), children: [] }); + i++; + } + + const tag = ordered ? 'ol' : 'ul'; + const body = items.map((it) => `
  • ${it.html}${it.children.join('')}
  • `).join(''); + return { html: `<${tag}>${body}`, next: i }; + } + + _renderMdLink(label, target) { + const isExternal = /^(?:https?:|mailto:|tel:|#|\/\/)/i.test(target); + if (isExternal) { + return `${label}`; + } + const raw = this._decodeEntities(target).replace(/^\.\//, ''); + return this._pathLink(label, raw); + } + + /** Build a clickable link for a vault-relative file or directory path. */ + _pathLink(display, rawPath) { + const clean = String(rawPath || '').replace(/^\.\//, ''); + const kind = this._classifyPath(clean); + const attr = this._escapeAttr(clean); + return `${display}`; + } + + _classifyPath(path) { + return FILE_EXT_RE.test(path || '') ? 'file' : 'dir'; + } + + _looksLikePath(value) { + if (!value) return false; + const p = value.trim(); + if (!p || /\s/.test(p)) return false; + if (!/^[\w./\\-]+$/.test(p)) return false; + return p.includes('/') || FILE_EXT_RE.test(p); + } + + _escapeHtml(s) { + return String(s) .replace(/&/g, '&') .replace(//g, '>') - .replace(/```(\w*)\n([\s\S]*?)```/g, '
    $2
    ') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') - .replace(/^[*\-+] (.+)$/gm, '
  • $1
  • ') - .replace(/^### (.+)$/gm, '

    $1

    ') - .replace(/^## (.+)$/gm, '

    $1

    ') - .replace(/\n\n/g, '

    ') - .replace(/\n/g, '
    '); - return `

    ${html}

    `; + .replace(/"/g, '"') + .replace(/'/g, '''); } - // ── History persistence ───────────────────────────────────────────── + _escapeAttr(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } - _historyKey() { + _decodeEntities(s) { + return String(s) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + } + + // ── Session history ───────────────────────────────────────────────── + // + // Each context (directory / documents / general) owns a list of sessions + // persisted in localStorage. A session is `{id, title, createdAt, + // updatedAt, messages}`; the header history button lists them so a previous + // conversation can be reopened or deleted. + + _contextKey() { const ctx = this._mode === MODE.DOCUMENTS ? documentsSignature(this._documents) : (this._directory || this._vault || 'general'); - return `bookslm-history-${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`; + return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`; } - _saveHistory() { + _sessionsKey() { + return `bookslm-sessions-${this._contextKey()}`; + } + + _legacyHistoryKey() { + return `bookslm-history-${this._contextKey()}`; + } + + _newSessionId() { + return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } + + _createSession(messages) { + const now = Date.now(); + return { id: this._newSessionId(), title: '', createdAt: now, updatedAt: now, messages: messages || [] }; + } + + _currentSession() { + return this._sessions.find((s) => s.id === this._currentSessionId) || null; + } + + _readSessions() { try { - localStorage.setItem(this._historyKey(), JSON.stringify(this._messages)); - } catch { /* */ } + const raw = localStorage.getItem(this._sessionsKey()); + if (!raw) return []; + const list = JSON.parse(raw); + if (!Array.isArray(list)) return []; + return list + .filter((s) => s && typeof s === 'object' && Array.isArray(s.messages)) + .map((s) => ({ + id: s.id || this._newSessionId(), + title: s.title || '', + createdAt: s.createdAt || Date.now(), + updatedAt: s.updatedAt || s.createdAt || Date.now(), + messages: s.messages, + })); + } catch { + return []; + } + } + + /** Import the pre-sessions single conversation, if any. */ + _migrateLegacyHistory() { + try { + const raw = localStorage.getItem(this._legacyHistoryKey()); + if (!raw) return null; + const messages = JSON.parse(raw); + if (!Array.isArray(messages) || !messages.length) return null; + const session = this._createSession(messages); + session.title = this._deriveTitle(messages); + return session; + } catch { + return null; + } } _loadHistory() { + this._sessions = this._readSessions(); + if (!this._sessions.length) { + const migrated = this._migrateLegacyHistory(); + if (migrated) this._sessions = [migrated]; + } + if (this._pendingNewSession) { + // A "new conversation" was requested before the panel existed + // (command palette): start a fresh session on top of the existing ones. + this._pendingNewSession = false; + this._sessions.unshift(this._createSession([])); + this._persistSessions(); + } else if (!this._sessions.length) { + this._sessions = [this._createSession([])]; + } + this._sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + const current = this._sessions[0]; + this._currentSessionId = current.id; + this._messages = Array.isArray(current.messages) ? current.messages : []; + } + + _persistSessions() { try { - const data = localStorage.getItem(this._historyKey()); - this._messages = data ? JSON.parse(data) : []; + localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions)); + } catch { /* quota / private mode */ } + } + + _saveHistory() { + const session = this._currentSession(); + if (session) { + session.messages = this._messages; + session.updatedAt = Date.now(); + if (!session.title) session.title = this._deriveTitle(this._messages); + } else { + const created = this._createSession(this._messages); + created.title = this._deriveTitle(this._messages); + this._sessions.unshift(created); + this._currentSessionId = created.id; + } + this._persistSessions(); + } + + _deriveTitle(messages) { + const firstUser = (messages || []).find((m) => m && m.role === 'user' && m.content && m.content.trim()); + if (!firstUser) return ''; + const line = firstUser.content.trim().split('\n')[0]; + return line.length > 60 ? line.slice(0, 57) + '…' : line; + } + + _loadSession(id) { + const session = this._sessions.find((s) => s.id === id); + if (!session) return; + this._currentSessionId = id; + this._messages = Array.isArray(session.messages) ? session.messages : []; + this._closeHistoryMenu(); + if (this._panel) { + this._renderMessages(); + this._showSuggestions(); + this._renderHistoryMenu(); + } + } + + _deleteSession(id) { + this._sessions = this._sessions.filter((s) => s.id !== id); + if (!this._sessions.length) { + const session = this._createSession([]); + this._sessions.push(session); + this._currentSessionId = session.id; + this._messages = session.messages; + } else if (id === this._currentSessionId) { + const next = this._sessions[0]; + this._currentSessionId = next.id; + this._messages = next.messages || []; + } + this._persistSessions(); + if (this._panel) { + this._renderMessages(); + this._showSuggestions(); + this._renderHistoryMenu(); + } + } + + _toggleHistoryMenu() { + if (!this._panel) return; + const menu = this._panel.querySelector('.bookslm-history-menu'); + if (!menu) return; + const nowHidden = menu.classList.toggle('hidden'); + if (!nowHidden) this._renderHistoryMenu(); + } + + _closeHistoryMenu() { + if (!this._panel) return; + const menu = this._panel.querySelector('.bookslm-history-menu'); + if (menu) menu.classList.add('hidden'); + } + + _renderHistoryMenu() { + if (!this._panel) return; + const menu = this._panel.querySelector('.bookslm-history-menu'); + if (!menu) return; + menu.innerHTML = ''; + + const sessions = [...this._sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + if (!sessions.length) { + const empty = document.createElement('div'); + empty.className = 'bookslm-history-empty'; + empty.textContent = t('bookslm.history_empty'); + menu.appendChild(empty); + return; + } + + for (const session of sessions) { + const item = document.createElement('div'); + item.className = 'bookslm-history-item'; + if (session.id === this._currentSessionId) item.classList.add('active'); + + const open = document.createElement('button'); + open.type = 'button'; + open.className = 'bookslm-history-open'; + const title = document.createElement('span'); + title.className = 'bookslm-history-title'; + title.textContent = session.title || t('bookslm.untitled'); + const date = document.createElement('span'); + date.className = 'bookslm-history-date'; + date.textContent = this._formatDate(session.updatedAt); + open.appendChild(title); + open.appendChild(date); + open.addEventListener('click', () => this._loadSession(session.id)); + + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'bookslm-history-delete'; + del.title = t('bookslm.session_delete'); + del.setAttribute('aria-label', t('bookslm.session_delete')); + del.innerHTML = ''; + del.addEventListener('click', (e) => { + e.stopPropagation(); + this._deleteSession(session.id); + }); + + item.appendChild(open); + item.appendChild(del); + menu.appendChild(item); + } + + if (typeof safeCreateIcons === 'function') safeCreateIcons(); + } + + _formatDate(ts) { + if (!ts) return ''; + try { + return new Date(ts).toLocaleString(); } catch { - this._messages = []; + return ''; } } } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 440e71f..d007aff 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1207,6 +1207,11 @@ "help.title": "ObsiGate Help", "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_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_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.", "help.toolbar_section": "AI toolbar", "help.tree_section": "Vault tree", "help.use_cases": "Use cases", @@ -1635,6 +1640,11 @@ "bookslm.no_context": "No files found in this directory", "bookslm.context_too_large": "Directory too large — some files were truncated", "bookslm.source": "Source", + "bookslm.session_history": "Session history", + "bookslm.session_delete": "Delete session", + "bookslm.history_empty": "No saved session", + "bookslm.untitled": "Untitled conversation", + "bookslm.no_vault": "No active vault to open this link", "palette.bookslm_open": "BooksLM: Open for current directory", "palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)", "palette.bookslm_new": "BooksLM: New conversation", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index acc9922..f64b53b 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1207,6 +1207,11 @@ "help.title": "Aide ObsiGate", "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_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_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.", "help.toolbar_section": "Barre d'outils AI", "help.tree_section": "Arborescence des vaults", "help.use_cases": "Cas d'usage", @@ -1635,6 +1640,11 @@ "bookslm.no_context": "Aucun fichier trouvé dans ce répertoire", "bookslm.context_too_large": "Répertoire trop volumineux — certains fichiers ont été tronqués", "bookslm.source": "Source", + "bookslm.session_history": "Historique des sessions", + "bookslm.session_delete": "Supprimer la session", + "bookslm.history_empty": "Aucune session enregistrée", + "bookslm.untitled": "Conversation sans titre", + "bookslm.no_vault": "Aucun vault actif pour ouvrir ce lien", "palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant", "palette.bookslm_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)", "palette.bookslm_new": "BooksLM: Nouvelle conversation", diff --git a/frontend/style.css b/frontend/style.css index a1878a4..330d39d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9226,6 +9226,58 @@ body.popup-mode .content-area { .bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; } .bookslm-suggestion:hover { background: var(--surface2); color: var(--text-primary); border-color: var(--accent); } +/* Formatted markdown inside assistant answers. */ +.bookslm-bubble.assistant > *:first-child { margin-top: 0; } +.bookslm-bubble.assistant > *:last-child { margin-bottom: 0; } +.bookslm-bubble.assistant p { margin: 6px 0; } +.bookslm-bubble.assistant h1, +.bookslm-bubble.assistant h2, +.bookslm-bubble.assistant h3, +.bookslm-bubble.assistant h4, +.bookslm-bubble.assistant h5, +.bookslm-bubble.assistant h6 { margin: 10px 0 6px; line-height: 1.3; font-weight: 600; } +.bookslm-bubble.assistant h1 { font-size: 1.35em; } +.bookslm-bubble.assistant h2 { font-size: 1.2em; } +.bookslm-bubble.assistant h3 { font-size: 1.1em; } +.bookslm-bubble.assistant h4, +.bookslm-bubble.assistant h5, +.bookslm-bubble.assistant h6 { font-size: 1em; } +.bookslm-bubble.assistant ul, +.bookslm-bubble.assistant ol { margin: 6px 0; padding-left: 20px; } +.bookslm-bubble.assistant li { margin: 2px 0; } +.bookslm-bubble.assistant blockquote { margin: 8px 0; padding: 4px 10px; + border-left: 3px solid var(--accent); background: rgba(0,0,0,0.12); + border-radius: 0 4px 4px 0; color: var(--text-secondary); } +.bookslm-bubble.assistant hr { border: none; border-top: 1px solid var(--border); margin: 10px 0; } +.bookslm-bubble.assistant table { border-collapse: collapse; margin: 8px 0; width: 100%; + display: block; overflow-x: auto; } +.bookslm-bubble.assistant th, +.bookslm-bubble.assistant td { border: 1px solid var(--border); padding: 4px 8px; + font-size: 0.92em; text-align: left; } +.bookslm-bubble.assistant th { background: rgba(0,0,0,0.15); } +.bookslm-bubble.assistant pre { max-width: 100%; } +.bookslm-bubble.assistant a { color: var(--accent); } +.bookslm-path-link { color: var(--accent); cursor: pointer; + text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; } +.bookslm-path-link:hover { text-decoration-style: solid; } +/* Session history dropdown. */ +.bookslm-history-menu { position: absolute; top: 48px; right: 10px; z-index: 20; + width: min(320px, calc(100% - 20px)); max-height: 60vh; overflow-y: auto; + background: var(--bg-primary); border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 8px 24px rgba(0,0,0,0.35); padding: 6px; } +.bookslm-history-menu.hidden { display: none; } +.bookslm-history-item { display: flex; align-items: stretch; gap: 4px; border-radius: 8px; } +.bookslm-history-item:hover { background: var(--surface2); } +.bookslm-history-item.active { background: var(--surface2); outline: 1px solid var(--accent); } +.bookslm-history-open { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; + background: none; border: none; cursor: pointer; text-align: left; padding: 7px 8px; + color: var(--text-primary); border-radius: 8px; } +.bookslm-history-title { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bookslm-history-date { font-size: 10px; color: var(--text-secondary); } +.bookslm-history-delete { background: none; border: none; cursor: pointer; color: var(--text-secondary); + padding: 0 8px; border-radius: 6px; display: flex; align-items: center; } +.bookslm-history-delete:hover { color: #f87171; background: rgba(248,113,113,0.12); } +.bookslm-history-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; } @media (max-width: 768px) { .bookslm-panel { width: 100%; diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 0233da5..b16fff4 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -102,7 +102,7 @@ async function main() { const ai = await import(pathToFileURL(path.join(JS_DIR, "ai.js")).href); const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href); - const { BooksLM } = bookslmMod; + const { BooksLM, MODE } = bookslmMod; const { collectOpenDocuments, documentsSignature } = bookslmMod; // ── 1. Rewrite modal resolves with the typed instruction ── @@ -357,11 +357,162 @@ async function main() { assert.ok(actions.querySelector(".bookslm-btn-export")); assert.ok(actions.querySelector(".bookslm-btn-fullscreen")); assert.ok(actions.querySelector(".bookslm-btn-close")); + assert.ok(actions.querySelector(".bookslm-btn-history"), "session history button"); + assert.ok(panel.querySelector(".bookslm-history-menu"), "session history menu host"); assert.ok(panel.querySelector(".bookslm-toolbar .bookslm-picker-host"), "picker on its own toolbar row"); assert.ok(panel.querySelector(".bookslm-input-hint"), "keyboard hint shown"); panel.remove(); }); + // ── 11. Formatted markdown rendering ── + await test("_renderMarkdown renders headings, lists, code and tables", () => { + const b = new BooksLM(); + const html = b._renderMarkdown([ + "# Title", + "", + "Some **bold** and `code` text.", + "", + "- one", + "- two", + "", + "| A | B |", + "| --- | --- |", + "| 1 | 2 |", + "", + "```js", + "const x = 1;", + "```", + ].join("\n")); + assert.ok(html.includes("

    Title

    "), "heading rendered"); + assert.ok(html.includes("bold"), "bold rendered"); + assert.ok(html.includes("code"), "inline code rendered"); + assert.ok(html.includes(""), "list rendered"); + assert.ok(html.includes("") && html.includes("") && html.includes(""), "table rendered"); + assert.ok(html.includes("language-js") && html.includes("const x = 1;"), "fenced code rendered"); + }); + + await test("_renderMarkdown escapes model HTML output", () => { + const b = new BooksLM(); + const html = b._renderMarkdown(''); + assert.ok(!html.includes(" { + const b = new BooksLM(); + const html = b._renderMarkdown("Ouvre `notes/a.md` puis `projets/2026` et [le guide](docs/guide.md)."); + assert.ok(html.includes('class="bookslm-path-link"'), "path links rendered"); + assert.ok(html.includes('data-path="notes/a.md"') && html.includes('data-kind="file"'), "file link classified"); + assert.ok(html.includes('data-path="projets/2026"') && html.includes('data-kind="dir"'), "dir link classified"); + assert.ok(html.includes('data-path="docs/guide.md"'), "markdown link linkified"); + }); + + await test("_renderMarkdown keeps external links external", () => { + const b = new BooksLM(); + const html = b._renderMarkdown("[site](https://example.com/page)"); + assert.ok(html.includes('href="https://example.com/page"'), "external link kept"); + 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", () => { + 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 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"]); + b._panel.remove(); + }); + + // ── 13. Session management ── + await test("sessions persist and can be reopened and deleted", () => { + localStorage.clear(); + const b = new BooksLM(); + b._panel = document.createElement("div"); + b._mode = MODE.DIRECTORY; + b._vault = "VaultSessions"; + b._directory = "notes"; + b._loadHistory(); + assert.equal(b._sessions.length, 1, "one session created on first load"); + + b._messages.push({ role: "user", content: "Première question" }); + b._messages.push({ role: "assistant", content: "Réponse" }); + b._saveHistory(); + const firstId = b._currentSessionId; + + b.newConversation(); + assert.equal(b._sessions.length, 2, "new conversation adds a session"); + assert.notEqual(b._currentSessionId, firstId, "current session switched"); + b._messages.push({ role: "user", content: "Deuxième" }); + b._saveHistory(); + + b._loadSession(firstId); + assert.equal(b._messages.length, 2, "previous session messages restored"); + assert.equal(b._messages[0].content, "Première question"); + const first = b._sessions.find((s) => s.id === firstId); + assert.ok(first && first.title.includes("Première"), "session titled from first user message"); + + b._deleteSession(firstId); + assert.equal(b._sessions.length, 1, "session deleted"); + assert.ok(!b._sessions.find((s) => s.id === firstId), "deleted session gone"); + }); + + await test("history menu lists saved sessions", () => { + localStorage.clear(); + const b = new BooksLM(); + b._mode = MODE.DIRECTORY; + b._vault = "VaultMenu"; + b._directory = "d"; + b._loadHistory(); + b._messages.push({ role: "user", content: "Hello menu" }); + b._saveHistory(); + + b._panel = b._render(); + document.body.appendChild(b._panel); + b._renderHistoryMenu(); + const items = b._panel.querySelectorAll(".bookslm-history-item"); + assert.equal(items.length, 1, "one session listed"); + assert.ok(items[0].textContent.includes("Hello menu"), "session title shown"); + b._panel.remove(); + }); + + await test("newConversation before open defers a fresh session", async () => { + localStorage.clear(); + const b = new BooksLM(); + b.newConversation(); + assert.equal(b._pendingNewSession, true, "fresh session deferred"); + await b.openContext({ mode: MODE.GENERAL }); + assert.equal(b._pendingNewSession, false, "deferred session applied on open"); + assert.equal(b._sessions.length, 1, "fresh session created"); + b.close(); + }); + + await test("legacy single-conversation history is migrated", () => { + localStorage.clear(); + const b = new BooksLM(); + b._mode = MODE.GENERAL; + localStorage.setItem( + b._legacyHistoryKey(), + JSON.stringify([{ role: "user", content: "Ancienne conversation" }]) + ); + b._loadHistory(); + assert.equal(b._sessions.length, 1, "legacy history imported as a session"); + assert.equal(b._messages[0].content, "Ancienne conversation"); + }); + // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) {
    A1