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
+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) {