feat(ai): section Fournisseur & modele compacte + correctifs menus @/ (#82, BUG-007, BUG-008)
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:
@@ -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) {
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user