feat(ai): rendu Markdown, liens fichiers/paths et sessions (#80)
CI / lint (push) Successful in 57s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m14s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

Assistant IA (BooksLM) :

- renderer Markdown complet (titres, listes, tableaux, citations, code) avec echappement HTML

- mentions fichiers/paths cliquables : ouverture du fichier / revelation dans l'arborescence

- gestion multi-sessions par contexte (historique, rechargement, suppression, migration)

- i18n FR/EN, aide in-app, styles, tests frontend (24)
This commit is contained in:
2026-09-11 16:14:21 -04:00
parent 240fd8586e
commit c55e3e0cbc
11 changed files with 906 additions and 36 deletions
+14
View File
@@ -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`,
+12 -1
View File
@@ -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** |
+55
View File
@@ -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).
+2 -2
View File
@@ -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)
+3
View File
@@ -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
+23
View File
@@ -4181,6 +4181,29 @@
Pour changer de provider par défaut :
<code>AI_DEFAULT_PROVIDER=openrouter</code>
</p>
<h3 data-i18n="help.assistant_panel">🧠 Panneau Assistant (BooksLM)</h3>
<p data-i18n="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.
</p>
<ul>
<li data-i18n="help.assistant_markdown">
Réponses formatées : titres, listes, tableaux, citations et
blocs de code.
</li>
<li data-i18n="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.
</li>
<li data-i18n="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.
</li>
</ul>
</section>
<!-- 🔗 Partage -->
+573 -32
View File
@@ -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 {
<span class="bookslm-subtitle"></span>
</div>
<div class="bookslm-header-actions">
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-fullscreen" title="${t('ai.fullscreen')}" aria-label="${t('ai.fullscreen')}"><i data-lucide="maximize-2" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-close" title="${t('ai.close')}" aria-label="${t('ai.close')}"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
</div>
<div class="bookslm-history-menu hidden"></div>
<div class="bookslm-toolbar">
<span class="bookslm-toolbar-label">${t('ai.model_settings')}</span>
<span class="bookslm-picker-host"></span>
@@ -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(`<p>${this._renderInline(para.join('\n'))}</p>`);
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(`<h${level}>${this._renderInline(heading[2])}</h${level}>`);
i++;
continue;
}
// Horizontal rule.
if (/^\s*(?:[-*_])\s*(?:[-*_]\s*){2,}$/.test(line)) {
flushPara();
out.push('<hr>');
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(`<blockquote>${this._renderMarkdown(quote.join('\n'))}</blockquote>`);
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(`<code>${this._pathLink(code, raw)}</code>`);
return put(`<code>${code}</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, '<strong>$1</strong>');
s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
// Preserve single line breaks (chat answers often rely on them).
s = s.replace(/\n/g, '<br>');
// 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 `<pre><code${cls}>${this._escapeHtml(code)}</code></pre>`;
}
_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) => `<th>${this._renderInline(c)}</th>`).join('');
const tbody = rows
.map((r) => `<tr>${r.map((c) => `<td>${this._renderInline(c)}</td>`).join('')}</tr>`)
.join('');
return { html: `<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`, 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 = `<input type="checkbox" disabled${task[1].toLowerCase() === 'x' ? ' checked' : ''}> `;
content = task[2];
}
items.push({ html: checkbox + this._renderInline(content), children: [] });
i++;
}
const tag = ordered ? 'ol' : 'ul';
const body = items.map((it) => `<li>${it.html}${it.children.join('')}</li>`).join('');
return { html: `<${tag}>${body}</${tag}>`, next: i };
}
_renderMdLink(label, target) {
const isExternal = /^(?:https?:|mailto:|tel:|#|\/\/)/i.test(target);
if (isExternal) {
return `<a href="${target}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
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 `<a href="#" class="bookslm-path-link" data-path="${attr}" data-kind="${kind}" title="${attr}">${display}</a>`;
}
_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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
return `<p>${html}</p>`;
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── History persistence ─────────────────────────────────────────────
_escapeAttr(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
_historyKey() {
_decodeEntities(s) {
return String(s)
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 = '<i data-lucide="trash-2" style="width:14px;height:14px"></i>';
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 '';
}
}
}
+10
View File
@@ -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",
+10
View File
@@ -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",
+52
View File
@@ -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%;
+152 -1
View File
@@ -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("<h1>Title</h1>"), "heading rendered");
assert.ok(html.includes("<strong>bold</strong>"), "bold rendered");
assert.ok(html.includes("<code>code</code>"), "inline code rendered");
assert.ok(html.includes("<ul><li>one</li><li>two</li></ul>"), "list rendered");
assert.ok(html.includes("<table>") && html.includes("<th>A</th>") && html.includes("<td>1</td>"), "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('<img src=x onerror="alert(1)">');
assert.ok(!html.includes("<img"), "raw HTML must be escaped");
assert.ok(html.includes("&lt;img"), "escaped output present");
});
// ── 12. File/path mentions become clickable links ──
await test("_renderMarkdown turns file and path mentions into links", () => {
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) {