feat(ai): section Fournisseur & modele compacte + correctifs menus @/ (#82, BUG-007, BUG-008)
CI / lint (push) Successful in 1m13s
CI / security (push) Successful in 43s
CI / test (push) Successful in 2m33s
CI / build (push) Successful in 39s
CI / e2e (push) Successful in 10m32s

Refonte de la section Fournisseur & modele de l'assistant : capacites regroupees dans une bulle d'information (survol/clic/appui long) au lieu d'une liste permanente, et liste de modeles avec recherche. Correctifs : detection Unicode + filtrage insensible aux accents des commandes / et mentions @, navigation clavier fiable, et prise en compte du contexte ad-hoc en mode General cote backend.
This commit is contained in:
2026-09-12 12:40:03 -04:00
parent f049e208b6
commit d6b7c0e9cb
14 changed files with 643 additions and 93 deletions
+171
View File
@@ -788,6 +788,177 @@ async function main() {
b._panel.remove();
});
// ── 38. Accent-insensitive `/` command filtering (BUG-007) ──
await test("slash command filter is accent-insensitive", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [{ id: "resume", label: "Résumé", icon: "📄", type: "skill", description: "d" }],
commands: [],
}),
};
}
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 = "/resume";
ta.selectionStart = ta.selectionEnd = 7;
b._onComposerInput();
await sleep(20);
let menu = b._panel.querySelector(".bookslm-command-menu");
assert.ok(!menu.classList.contains("hidden"), "ascii query keeps the menu open");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
ta.value = "/résumé";
ta.selectionStart = ta.selectionEnd = 7;
b._onComposerInput();
await sleep(20);
menu = b._panel.querySelector(".bookslm-command-menu");
assert.ok(!menu.classList.contains("hidden"), "accented query keeps the menu open");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
b._panel.remove();
localStorage.clear();
});
// ── 39. Accented `@` mentions + all-vault fallback (BUG-008) ──
await test("mention menu accepts accents and searches all vaults without a vault", async () => {
localStorage.clear();
let lastUrl = "";
globalThis.fetch = async (url) => {
lastUrl = String(url);
if (lastUrl.includes("/api/tree-search")) {
return {
ok: true, status: 200,
json: async () => ({ results: [{ path: "Café/note.md", type: "file" }] }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._vault = null;
const ta = b._panel.querySelector("textarea");
ta.value = "@café";
ta.selectionStart = ta.selectionEnd = 5;
b._onComposerInput();
await sleep(20);
const menu = b._panel.querySelector(".bookslm-mention-menu");
assert.ok(!menu.classList.contains("hidden"), "accented mention keeps the menu open");
assert.ok(lastUrl.includes("vault=all"), "no vault -> searches all vaults");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
b._panel.remove();
localStorage.clear();
});
// ── 40. Keyboard navigation in the command menu (BUG-007) ──
await test("arrow keys move the active item in the command menu", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [
{ id: "a", label: "Alpha", type: "skill", description: "d" },
{ id: "b", label: "Beta", type: "skill", description: "d" },
{ id: "c", label: "Gamma", type: "skill", description: "d" },
],
commands: [],
}),
};
}
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 = "/";
ta.selectionStart = ta.selectionEnd = 1;
b._onComposerInput();
await sleep(20);
const items = () => Array.from(b._panel.querySelectorAll(".bookslm-command-menu .bookslm-menu-item"));
assert.equal(items().length, 3);
assert.ok(items()[0].classList.contains("active"), "first item active by default");
const down = new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true });
ta.dispatchEvent(down);
assert.ok(down.defaultPrevented, "ArrowDown handled by the menu");
assert.ok(items()[1].classList.contains("active"), "second item active after ArrowDown");
ta.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true }));
assert.ok(items()[0].classList.contains("active"), "back to first after ArrowUp");
b._panel.remove();
localStorage.clear();
});
// ── 41. Compact picker: model search + capability info bubble (#82) ──
await test("picker model search filters and info reveals capabilities", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/status")) {
return { ok: true, status: 200, json: async () => ({ configured: true, providers: { deepseek: { available: true } } }) };
}
if (u.includes("/api/config/ai-models")) {
return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat", "deepseek-reasoner", "vision-model"], source: "api" }) };
}
if (u.includes("/api/ai/model-capabilities")) {
return {
ok: true, status: 200,
json: async () => ({
capabilities: { chat: true, embeddings: false, rerank: false, images: false, video: false, audio_speech: false, audio_transcription: false, vision: false },
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "deepseek" }));
const picker = await ai.buildAIPickerUI();
assert.ok(picker, "picker built");
document.body.appendChild(picker);
await sleep(30);
const hidden = picker.querySelector(".ai-picker-model");
assert.ok(hidden && hidden.hidden, "hidden native select kept for admin commands");
assert.ok(hidden.querySelectorAll("option").length >= 4, "native select holds default + models");
picker.querySelector(".ai-picker-model-trigger").click();
const pop = picker.querySelector(".ai-picker-model-pop");
assert.ok(!pop.classList.contains("hidden"), "model popover opens");
assert.equal(pop.querySelectorAll(".ai-picker-model-option").length, 4, "default + 3 models");
const search = picker.querySelector(".ai-picker-model-search");
search.value = "reason";
search.dispatchEvent(new w.Event("input", { bubbles: true }));
const filtered = pop.querySelectorAll(".ai-picker-model-option");
assert.equal(filtered.length, 1, "search filters the list");
assert.equal(filtered[0].dataset.value, "deepseek-reasoner");
filtered[0].dispatchEvent(new w.Event("mousedown", { bubbles: true }));
assert.equal(hidden.value, "deepseek-reasoner", "selection updates the native select");
const stored = JSON.parse(localStorage.getItem("obsigate_ai_picker"));
assert.equal(stored.model, "deepseek-reasoner");
await sleep(20);
const info = picker.querySelector(".ai-picker-info");
assert.ok(!info.classList.contains("hidden"), "info button visible once capabilities loaded");
info.click();
const capsPop = picker.querySelector(".ai-picker-caps-pop");
assert.ok(!capsPop.classList.contains("hidden"), "capability bubble opens");
assert.equal(capsPop.querySelectorAll(".ai-cap-item").length, 8, "all capability flags listed in the bubble");
picker.remove();
localStorage.clear();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
+50
View File
@@ -747,6 +747,56 @@ class TestBooksLMChatModes:
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
assert resp.status_code != 422
def test_general_chat_includes_adhoc_files(self, bookslm_client, monkeypatch):
"""`@` ad-hoc files must be read even in General mode (BUG-008)."""
import backend.bookslm_routes as routes
captured = {}
async def fake_stream(messages, **kwargs):
captured["messages"] = messages
yield "ok"
monkeypatch.setattr(routes, "stream_completion", fake_stream)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={
"mode": "general",
"vault": "TestVault",
"extra_files": ["notes/note1.md"],
"message": "Résume",
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text[:200]
system_prompt = captured["messages"][0]["content"]
assert "First note content" in system_prompt, "ad-hoc file content missing from prompt"
def test_general_chat_without_adhoc_ignores_vault(self, bookslm_client, monkeypatch):
"""General mode without ad-hoc context stays vault-independent."""
import backend.bookslm_routes as routes
captured = {}
async def fake_stream(messages, **kwargs):
captured["messages"] = messages
yield "ok"
monkeypatch.setattr(routes, "stream_completion", fake_stream)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={"mode": "general", "vault": "UnknownVault", "message": "Bonjour"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text[:200]
assert captured["messages"][0]["content"]
# ── Tool-calling agent endpoint ───────────────────────────────────────