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:
+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