feat(ai): assistant AI multi-contexte + ergonomie sidebar et clavier
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Contexte: - Repertoire (mode historique BooksLM, via menu contextuel dossier) - Documents (bouton flottant quand des documents sont ouverts) - General (bouton flottant sans document: aide app + creation de fichiers) - Le contexte est affiche dans le header; suggestions adaptees au mode - Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto) Backend: - /api/ai/bookslm/context et /chat acceptent mode + context_files - collect_files_context(), empty_context(), build_general_system_prompt() - mode documents degrade vers general si aucun fichier lisible Corrige: - bouton flottant passait le chemin d'un fichier comme repertoire -> erreur 'Aucun fichier markdown trouve dans ce dossier' - panneau precedent masque (localStorage) ne se rouvrait plus - selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles) - Entree envoie, Ctrl+Entree insere un saut de ligne Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
This commit is contained in:
+137
-2
@@ -103,6 +103,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 { collectOpenDocuments, documentsSignature } = bookslmMod;
|
||||
|
||||
// ── 1. Rewrite modal resolves with the typed instruction ──
|
||||
await test("rewrite modal returns the typed instruction on confirm", async () => {
|
||||
@@ -226,12 +227,146 @@ async function main() {
|
||||
assert.deepEqual(missingEn, [], `missing EN keys: ${missingEn.join(", ")}`);
|
||||
});
|
||||
|
||||
// ── 7. Context detection: open documents across tabs and panes ──
|
||||
await test("collectOpenDocuments merges tabs and panes without duplicates", () => {
|
||||
window.TabManager = {
|
||||
_activeTabId: "t1",
|
||||
_tabs: [
|
||||
{ id: "t1", vault: "V", path: "a.md" },
|
||||
{ id: "t2", vault: "V", path: "b.md" },
|
||||
],
|
||||
};
|
||||
window.PaneManager = {
|
||||
tabManagers: {
|
||||
0: {
|
||||
_activeTabId: "p1",
|
||||
_tabs: [
|
||||
{ id: "p1", vault: "V", path: "b.md" },
|
||||
{ id: "p2", vault: "W", path: "c.md" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const docs = collectOpenDocuments();
|
||||
assert.equal(docs.length, 3, "b.md must be de-duplicated");
|
||||
assert.equal(docs[0].path, "a.md", "active tab first");
|
||||
delete window.TabManager;
|
||||
delete window.PaneManager;
|
||||
});
|
||||
|
||||
await test("documentsSignature is stable", () => {
|
||||
const sig = documentsSignature([{ vault: "V", path: "a.md" }, { vault: "V", path: "b.md" }]);
|
||||
assert.equal(sig, "V/a.md\u0000V/b.md");
|
||||
assert.equal(documentsSignature([]), "");
|
||||
});
|
||||
|
||||
await test("openForCurrentContext picks documents when tabs are open", async () => {
|
||||
window.TabManager = {
|
||||
_activeTabId: "t1",
|
||||
_tabs: [
|
||||
{ id: "t1", vault: "V", path: "notes/a.md" },
|
||||
{ id: "t2", vault: "V", path: "notes/b.md" },
|
||||
],
|
||||
};
|
||||
let ctxBody = null;
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/ai/status")) {
|
||||
return { ok: true, status: 200, json: async () => ({ configured: true, providers: {} }) };
|
||||
}
|
||||
if (u.includes("/api/ai/bookslm/context")) {
|
||||
ctxBody = JSON.parse(opts.body);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ files: [{ path: "notes/a.md" }], total_chars: 10, file_count: 1, scope: "documents", max_total_chars: 200000 }),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
await b.openForCurrentContext();
|
||||
assert.equal(b._mode, "documents");
|
||||
assert.equal(ctxBody.mode, "documents");
|
||||
assert.deepEqual(ctxBody.context_files, ["notes/a.md", "notes/b.md"]);
|
||||
await sleep(10);
|
||||
assert.ok(b._panel.classList.contains("open"));
|
||||
b.close();
|
||||
delete window.TabManager;
|
||||
});
|
||||
|
||||
await test("openForCurrentContext falls back to general with no document", async () => {
|
||||
window.TabManager = { _activeTabId: null, _tabs: [] };
|
||||
const b = new BooksLM();
|
||||
await b.openForCurrentContext();
|
||||
assert.equal(b._mode, "general");
|
||||
await sleep(10);
|
||||
assert.ok(b._panel.classList.contains("open"));
|
||||
assert.ok(!b._panel.classList.contains("hidden"), "explicit open must reveal the panel");
|
||||
b.close();
|
||||
delete window.TabManager;
|
||||
});
|
||||
|
||||
// ── 8. Keyboard behaviour: Enter sends, Ctrl+Enter adds a line ──
|
||||
await test("Enter submits and Ctrl+Enter inserts a newline", () => {
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._isOpen = true;
|
||||
let sent = 0;
|
||||
b._sendMessage = () => { sent++; };
|
||||
|
||||
const textarea = b._panel.querySelector("textarea");
|
||||
textarea.value = "hello";
|
||||
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
assert.equal(sent, 1, "Enter should submit");
|
||||
|
||||
textarea.value = "line1";
|
||||
textarea.selectionStart = textarea.selectionEnd = 5;
|
||||
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
assert.equal(sent, 1, "Ctrl+Enter must not submit");
|
||||
assert.ok(textarea.value.includes("\n"), "Ctrl+Enter should insert a newline");
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── 9. General assistant action blocks ──
|
||||
await test("_extractActions parses obsigate-action blocks", () => {
|
||||
const b = new BooksLM();
|
||||
const raw = 'Je crée le fichier.\n```obsigate-action\n{"action":"create_file","vault":"V","path":"a.md","content":"# A"}\n```';
|
||||
const { text, actions } = b._extractActions(raw);
|
||||
assert.equal(actions.length, 1);
|
||||
assert.equal(actions[0].action, "create_file");
|
||||
assert.equal(actions[0].path, "a.md");
|
||||
assert.ok(!text.includes("obsigate-action"), "action block removed from display text");
|
||||
});
|
||||
|
||||
await test("_extractActions ignores malformed blocks", () => {
|
||||
const b = new BooksLM();
|
||||
const { text, actions } = b._extractActions("```obsigate-action\nnot json\n```");
|
||||
assert.equal(actions.length, 0);
|
||||
assert.ok(text.length >= 0);
|
||||
});
|
||||
|
||||
// ── 10. Header ergonomics: actions always visible, picker on its own row ──
|
||||
await test("assistant header exposes context, actions and a picker row", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
const actions = panel.querySelector(".bookslm-header-actions");
|
||||
assert.ok(actions, "header actions container");
|
||||
assert.ok(actions.querySelector(".bookslm-btn-new"));
|
||||
assert.ok(actions.querySelector(".bookslm-btn-export"));
|
||||
assert.ok(actions.querySelector(".bookslm-btn-fullscreen"));
|
||||
assert.ok(actions.querySelector(".bookslm-btn-close"));
|
||||
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();
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
|
||||
@@ -195,7 +195,7 @@ function testAIFabModuleExists() {
|
||||
const content = readFileSync(AIFAB_PATH, 'utf-8');
|
||||
assert.ok(content.length > 0, 'ai-fab.js must be a non-empty file');
|
||||
assert.ok(/from\s+['"]\.\/bookslm\.js['"]/.test(content), 'ai-fab.js must import from ./bookslm.js');
|
||||
assert.ok(/from\s+['"]\.\/state\.js['"]/.test(content), 'ai-fab.js must import from ./state.js');
|
||||
assert.ok(/from\s+['"]\.\/utils\.js['"]/.test(content), 'ai-fab.js must import from ./utils.js');
|
||||
assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'ai-fab.js must import from ./i18n.js');
|
||||
console.log(' ✓ ai-fab.js exists and imports the expected modules');
|
||||
}
|
||||
|
||||
@@ -574,3 +574,175 @@ class TestBooksLMChatEndpoint:
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(sub, ignore_errors=True)
|
||||
|
||||
|
||||
# ── Unit tests: explicit documents context + general prompt ────────────
|
||||
|
||||
|
||||
class TestCollectFilesContext:
|
||||
"""Tests for collect_files_context() (Documents mode)."""
|
||||
|
||||
def test_collects_only_requested_files(self, tmp_path):
|
||||
from backend.bookslm import collect_files_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
(vault / "sub").mkdir(parents=True)
|
||||
(vault / "a.md").write_text("# A", encoding="utf-8")
|
||||
(vault / "b.md").write_text("# B", encoding="utf-8")
|
||||
(vault / "sub" / "c.md").write_text("# C", encoding="utf-8")
|
||||
|
||||
result = collect_files_context(vault, ["a.md", "sub/c.md"], scope="documents")
|
||||
|
||||
assert result["scope"] == "documents"
|
||||
assert result["file_count"] == 2
|
||||
assert [f["path"] for f in result["files"]] == ["a.md", "sub/c.md"]
|
||||
assert result["directory_tree"] == ""
|
||||
|
||||
def test_ignores_paths_outside_vault(self, tmp_path):
|
||||
from backend.bookslm import collect_files_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "ok.md").write_text("# ok", encoding="utf-8")
|
||||
outside = tmp_path / "outside.md"
|
||||
outside.write_text("# secret", encoding="utf-8")
|
||||
|
||||
result = collect_files_context(vault, ["../outside.md", "ok.md"])
|
||||
|
||||
assert result["file_count"] == 1
|
||||
assert result["files"][0]["path"] == "ok.md"
|
||||
|
||||
def test_deduplicates_paths(self, tmp_path):
|
||||
from backend.bookslm import collect_files_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "dup.md").write_text("# dup", encoding="utf-8")
|
||||
|
||||
result = collect_files_context(vault, ["dup.md", "dup.md"])
|
||||
|
||||
assert result["file_count"] == 1
|
||||
|
||||
def test_empty_context(self):
|
||||
from backend.bookslm import empty_context
|
||||
|
||||
ctx = empty_context("general")
|
||||
assert ctx["file_count"] == 0
|
||||
assert ctx["files"] == []
|
||||
assert ctx["scope"] == "general"
|
||||
|
||||
|
||||
class TestGeneralPrompt:
|
||||
def test_general_prompt_lists_vaults(self):
|
||||
from backend.bookslm import build_general_system_prompt
|
||||
|
||||
prompt = build_general_system_prompt(["Alpha", "Beta"])
|
||||
assert "Alpha" in prompt
|
||||
assert "Beta" in prompt
|
||||
assert "obsigate-action" in prompt
|
||||
|
||||
def test_documents_prompt_scope(self):
|
||||
from backend.bookslm import build_system_prompt
|
||||
|
||||
context = {
|
||||
"files": [{"path": "a.md", "title": "A", "content": "hello"}],
|
||||
"file_count": 1,
|
||||
"total_chars": 5,
|
||||
"directory_tree": "",
|
||||
}
|
||||
prompt = build_system_prompt(context, scope="documents")
|
||||
assert "Documents ouverts" in prompt
|
||||
assert "hello" in prompt
|
||||
|
||||
|
||||
class TestBooksLMContextModes:
|
||||
"""Tests for the mode-aware /context endpoint."""
|
||||
|
||||
def test_general_mode_needs_no_vault(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"mode": "general"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["scope"] == "general"
|
||||
assert data["file_count"] == 0
|
||||
assert data["files"] == []
|
||||
|
||||
def test_documents_mode_returns_selected_files(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={
|
||||
"mode": "documents",
|
||||
"vault": "TestVault",
|
||||
"context_files": ["notes/note1.md"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["scope"] == "documents"
|
||||
assert data["file_count"] == 1
|
||||
assert data["files"][0]["path"] == "notes/note1.md"
|
||||
|
||||
def test_documents_mode_without_files_degrades_to_general(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"mode": "documents", "vault": "TestVault", "context_files": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["scope"] == "general"
|
||||
assert data["file_count"] == 0
|
||||
|
||||
def test_documents_mode_requires_vault(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"mode": "documents", "context_files": ["notes/note1.md"]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_unknown_mode_falls_back_to_directory(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"mode": "bogus", "vault": "TestVault", "directory": "notes"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["scope"] == "directory"
|
||||
|
||||
|
||||
class TestBooksLMChatModes:
|
||||
def test_general_chat_does_not_require_vault(self, bookslm_client):
|
||||
"""General mode must not 400/404 on a missing vault (only 500 if no key)."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={"mode": "general", "message": "Bonjour"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
|
||||
assert resp.status_code != 422
|
||||
|
||||
def test_documents_chat_accepts_context_files(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={
|
||||
"mode": "documents",
|
||||
"vault": "TestVault",
|
||||
"context_files": ["notes/note1.md"],
|
||||
"message": "Résume",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
|
||||
assert resp.status_code != 422
|
||||
|
||||
Reference in New Issue
Block a user