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
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:
@@ -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
@@ -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** |
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── History persistence ─────────────────────────────────────────────
|
||||
_escapeAttr(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.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 = '<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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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("<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) {
|
||||
|
||||
Reference in New Issue
Block a user