feat(ai): commandes @/ & skills, analyse d'images et capacites des modeles (#81)
CI / lint (push) Successful in 1m10s
CI / security (push) Successful in 43s
CI / test (push) Successful in 2m34s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 10m59s

This commit is contained in:
2026-09-12 11:38:46 -04:00
parent 88bb817a62
commit f049e208b6
25 changed files with 2581 additions and 31 deletions
+181
View File
@@ -607,6 +607,187 @@ async function main() {
localStorage.clear();
});
// ── 29. Resizable panel bounds and persistence (#81) ──
await test("panel width is clamped and persisted", async () => {
localStorage.clear();
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
assert.equal(b._clampPanelWidth(100), 320, "min width enforced");
assert.equal(b._clampPanelWidth(5000), 1000, "max width enforced");
localStorage.setItem("obsigate-bookslm-width", "640");
b._applyPanelWidth();
assert.equal(b._panel.style.width, "640px", "persisted width applied");
assert.ok(b._panel.querySelector(".bookslm-resize-handle"), "resize handle present");
b._panel.remove();
localStorage.clear();
});
// ── 30. Ad-hoc context chips (#81) ──
await test("ad-hoc files and directories render as chips", async () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._addAdhocFile("notes/a.md");
b._addAdhocDir("projets/");
const chips = b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip");
assert.equal(chips.length, 2, "one chip per ad-hoc entry");
assert.ok(b._adhocDirs[0].path === "projets", "trailing slash stripped");
b._removeAdhocFile("notes/a.md");
assert.equal(b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip").length, 1);
b._panel.remove();
});
// ── 31. Chat payload carries extras, images and skill (#81) ──
await test("send payload includes extra context, images and skill", async () => {
localStorage.clear();
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._mode = MODE.GENERAL;
b._vault = "V";
b._messages = [];
b._adhocFiles = [{ path: "a.md" }];
b._adhocDirs = [{ path: "sub" }];
b._images = [{ path: "pics/x.png", name: "x.png" }];
b._activeSkill = "research";
b._panel.querySelector("textarea").value = "Analyse";
let posted = null;
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
};
await b._sendMessage();
assert.ok(posted, "chat request posted");
assert.deepEqual(posted.extra_files, ["a.md"]);
assert.deepEqual(posted.extra_directories, ["sub"]);
assert.deepEqual(posted.images, [{ path: "pics/x.png" }]);
assert.equal(posted.skill, "research");
b._panel.remove();
localStorage.clear();
});
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
await test("images route to /chat even in agent mode", async () => {
const b = new BooksLM();
b._agentMode = true;
let url = "";
globalThis.fetch = async (u) => {
url = String(u);
return { ok: true, status: 200, json: async () => ({}) };
};
await b._postChat({ images: [{ data: "AAA", mime_type: "image/png" }] });
assert.ok(url.includes("/api/ai/bookslm/chat"), "image request uses /chat");
await b._postChat({ images: [] });
assert.ok(url.includes("/api/ai/bookslm/agent"), "text request uses /agent in agent mode");
});
// ── 33. Vision gate (#81) ──
await test("_modelSupportsVision reflects selected model capabilities", async () => {
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
provider: "deepseek", model: "deepseek-chat", capabilities: { chat: true, vision: false },
}));
const b = new BooksLM();
assert.equal(await b._modelSupportsVision(), false);
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
provider: "qwencloud", model: "qwen-vl-max", capabilities: { chat: true, vision: true },
}));
assert.equal(await b._modelSupportsVision(), true);
localStorage.clear();
});
// ── 34. `/` command menu and selection (#81) ──
await test("slash command menu lists skills and selects one", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [{ id: "research", label: "Recherche", icon: "🔎", type: "skill", description: "d" }],
commands: [{ id: "help", label: "Aide", icon: "❓", type: "admin", description: "h" }],
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = "/res";
ta.selectionStart = ta.selectionEnd = 4;
await b._showCommandMenu("res");
const menu = b._panel.querySelector(".bookslm-command-menu");
assert.ok(!menu.classList.contains("hidden"), "command menu shown");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
b._selectCommand({ id: "research", label: "Recherche", type: "skill" });
assert.equal(b._activeSkill, "research");
assert.ok(b._panel.querySelector(".bookslm-chip-skill"), "skill chip rendered");
b._panel.remove();
});
// ── 35. `@` mention menu adds ad-hoc context / images (#81) ──
await test("mention menu adds files, directories and images", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/tree-search")) {
return {
ok: true, status: 200,
json: async () => ({ results: [
{ path: "notes/a.md", type: "file" },
{ path: "notes", type: "dir" },
] }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._vault = "V";
await b._showMentionMenu("notes");
const menu = b._panel.querySelector(".bookslm-mention-menu");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2);
b._selectMention({ id: "notes/a.md", type: "file" });
assert.ok(b._adhocFiles.some((f) => f.path === "notes/a.md"));
b._selectMention({ id: "notes", type: "dir" });
assert.ok(b._adhocDirs.some((d) => d.path === "notes"));
b._selectMention({ id: "pics/x.png", type: "file" });
assert.ok(b._images.some((img) => img.path === "pics/x.png"));
b._panel.remove();
});
// ── 36. Capability checklist rendering (#81) ──
await test("capability list renders checked and unchecked flags", () => {
const caps = {
chat: true, embeddings: false, rerank: false, images: false,
video: false, audio_speech: false, audio_transcription: false, vision: true,
};
const box = ai.renderCapabilityList(caps);
assert.equal(box.querySelectorAll(".ai-cap-item").length, 8);
assert.equal(box.querySelectorAll(".ai-cap-item.on").length, 2);
assert.ok(box.textContent.includes("☑"));
assert.ok(box.textContent.includes("□"));
});
// ── 37. Admin commands handled locally (#81) ──
await test("admin commands are parsed without an LLM call", async () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._messages = [];
let fetched = false;
globalThis.fetch = async () => { fetched = true; return { ok: true, status: 200, json: async () => ({}) }; };
const handled = b._maybeRunAdminWithArg("/model deepseek-chat");
assert.equal(handled, true);
assert.equal(fetched, false, "no LLM request for admin command");
assert.ok(b._messages.length === 1, "assistant note pushed");
b._panel.remove();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {