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) {
+31
View File
@@ -256,6 +256,37 @@ class TestListModelsEndpoint:
assert hdrs["api-key"] == "fake-xiaomi-key"
assert "authorization" not in hdrs, f"Authorization leaked: {hdrs!r}"
def test_models_include_capabilities(self, admin_client, monkeypatch):
"""The model list must expose per-model capability flags (#81)."""
from backend import ai as aimod
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
token = _login_admin(admin_client)
resp = admin_client.get(
"/api/config/ai-models",
params={"provider": "qwencloud"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
caps = data.get("capabilities") or {}
assert "qwen-vl-max" in caps
assert caps["qwen-vl-max"]["vision"] is True
def test_model_capabilities_endpoint(self, admin_client):
"""GET /api/ai/model-capabilities returns the curated flags."""
token = _login_admin(admin_client)
resp = admin_client.get(
"/api/ai/model-capabilities",
params={"provider": "deepseek", "model": "deepseek-chat"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["provider"] == "deepseek"
assert data["capabilities"]["chat"] is True
assert data["capabilities"]["vision"] is False
def test_xiaomi_test_endpoint_uses_real_url(self, admin_client, monkeypatch):
"""The /api/config/ai-keys/test endpoint must also use api.xiaomimimo.com.
+113
View File
@@ -0,0 +1,113 @@
"""Tests for multimodal (vision) support and ad-hoc context (#81)."""
from __future__ import annotations
import base64
from backend.ai_chat import _content_to_gemini_parts, _gemini_contents
from backend.bookslm import (
collect_adhoc_context,
is_image_path,
load_vault_image_data_url,
merge_contexts,
)
class TestGeminiParts:
def test_plain_string(self):
assert _content_to_gemini_parts("hello") == [{"text": "hello"}]
def test_none_becomes_empty_text(self):
assert _content_to_gemini_parts(None) == [{"text": ""}]
def test_text_and_image_data_url(self):
raw = base64.b64encode(b"\x89PNG").decode()
parts = _content_to_gemini_parts([
{"type": "text", "text": "décris"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{raw}"}},
])
assert parts[0] == {"text": "décris"}
assert parts[1] == {"inlineData": {"mimeType": "image/png", "data": raw}}
def test_remote_image_url_becomes_file_data(self):
parts = _content_to_gemini_parts([
{"type": "image_url", "image_url": {"url": "https://x.test/a.png"}},
])
assert parts == [{"fileData": {"fileUri": "https://x.test/a.png"}}]
def test_gemini_contents_handles_multimodal_user_message(self):
raw = base64.b64encode(b"img").decode()
system, contents = _gemini_contents([
{"role": "system", "content": "sys"},
{"role": "user", "content": [
{"type": "text", "text": "hi"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{raw}"}},
]},
])
assert system == "sys"
assert contents[0]["role"] == "user"
assert contents[0]["parts"][1]["inlineData"]["mimeType"] == "image/jpeg"
class TestImageHelpers:
def test_is_image_path(self):
assert is_image_path("a/b.png")
assert is_image_path("photo.JPEG")
assert not is_image_path("note.md")
def test_load_vault_image_data_url(self, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
(vault / "pic.png").write_bytes(b"PNGDATA")
url = load_vault_image_data_url(vault, "pic.png")
assert url is not None
assert url.startswith("data:image/png;base64,")
assert base64.b64decode(url.split(",", 1)[1]) == b"PNGDATA"
def test_load_rejects_path_traversal(self, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
outside = tmp_path / "outside.png"
outside.write_bytes(b"x")
assert load_vault_image_data_url(vault, "../outside.png") is None
def test_load_rejects_non_image(self, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note.md").write_text("hi", encoding="utf-8")
assert load_vault_image_data_url(vault, "note.md") is None
class TestAdhocContext:
def test_files_and_directories(self, tmp_path):
vault = tmp_path / "vault"
(vault / "sub").mkdir(parents=True)
(vault / "a.md").write_text("# A", encoding="utf-8")
(vault / "sub" / "b.md").write_text("# B", encoding="utf-8")
ctx = collect_adhoc_context(vault, files=["a.md"], directories=["sub"])
paths = {f["path"] for f in ctx["files"]}
assert paths == {"a.md", "sub/b.md"}
def test_deduplicates(self, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
(vault / "a.md").write_text("# A", encoding="utf-8")
ctx = collect_adhoc_context(vault, files=["a.md"], directories=["."])
assert ctx["file_count"] == 1
def test_merge_contexts(self):
base = {
"files": [{"path": "a.md", "content": "aaa"}],
"file_count": 1, "total_chars": 3, "directory_tree": "tree", "scope": "directory",
}
extra = {
"files": [
{"path": "a.md", "content": "aaa"},
{"path": "b.md", "content": "bb"},
],
"file_count": 2, "total_chars": 5, "directory_tree": "", "scope": "directory",
}
merged = merge_contexts(base, extra)
assert [f["path"] for f in merged["files"]] == ["a.md", "b.md"]
assert merged["total_chars"] == 5
+86
View File
@@ -0,0 +1,86 @@
"""Tests for the curated model-capability table (#81)."""
from __future__ import annotations
from backend.model_capabilities import (
CAPABILITY_KEYS,
get_capabilities_for_models,
get_model_capabilities,
model_supports_vision,
)
class TestCapabilityShape:
def test_every_result_has_all_keys(self):
for provider, model in [
("deepseek", "deepseek-chat"),
("gemini", "gemini-2.0-flash"),
("qwencloud", "qwen-vl-max"),
("openrouter", "unknown-model"),
]:
caps = get_model_capabilities(provider, model)
assert set(caps.keys()) == set(CAPABILITY_KEYS)
assert all(isinstance(v, bool) for v in caps.values())
class TestVisionDetection:
def test_vision_models_detected(self):
assert model_supports_vision("qwencloud", "qwen-vl-max")
assert model_supports_vision("openrouter", "openai/gpt-4o")
assert model_supports_vision("gemini", "gemini-2.0-flash")
assert model_supports_vision("mistral", "pixtral-large-latest")
def test_text_models_not_vision(self):
assert not model_supports_vision("deepseek", "deepseek-chat")
assert not model_supports_vision("xiaomi", "mimo-v2.5-pro")
def test_vision_implies_chat(self):
caps = get_model_capabilities("qwencloud", "qwen-vl-plus")
assert caps["vision"] is True
assert caps["chat"] is True
class TestSpecialModalities:
def test_embeddings(self):
caps = get_model_capabilities("openai", "text-embedding-3-small")
assert caps["embeddings"] is True
assert caps["chat"] is False
def test_rerank(self):
caps = get_model_capabilities("nvidia", "nvidia/llama-3.2-nv-rerankqa-1b-v2")
assert caps["rerank"] is True
def test_audio_transcription(self):
caps = get_model_capabilities("xiaomi", "mimo-v2.5-asr")
assert caps["audio_transcription"] is True
def test_audio_speech(self):
caps = get_model_capabilities("xiaomi", "mimo-v2.5-tts")
assert caps["audio_speech"] is True
def test_image_generation(self):
caps = get_model_capabilities("openai", "dall-e-3")
assert caps["images"] is True
class TestProviderDefaults:
def test_unknown_model_uses_provider_default(self):
caps = get_model_capabilities("deepseek", "some-new-model")
assert caps["chat"] is True
assert caps["vision"] is False
def test_unknown_provider_defaults_to_chat(self):
caps = get_model_capabilities("nope", "mystery")
assert caps["chat"] is True
def test_empty_model_uses_provider_default(self):
assert get_model_capabilities("gemini", "")["vision"] is True
class TestBatch:
def test_capabilities_for_models_map(self):
result = get_capabilities_for_models(
"qwencloud", ["qwen-max", "qwen-vl-max"]
)
assert result["qwen-max"]["vision"] is False
assert result["qwen-vl-max"]["vision"] is True
+227
View File
@@ -0,0 +1,227 @@
"""Tests for the AI assistant skills & slash-commands (#81)."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
import backend.skills as skills_mod
from backend.skills import (
BUILTIN_SKILLS,
create_user_skill,
delete_user_skill,
get_skill_prompt,
list_skills,
list_user_skills,
)
@pytest.fixture
def skill_store(tmp_path, monkeypatch):
"""Point the skills store at an isolated temp file."""
store = tmp_path / "skills.json"
monkeypatch.setattr(skills_mod, "SKILLS_FILE", store)
return store
USER = {"username": "alice"}
class TestBuiltinSkills:
def test_expected_skills_present(self):
ids = {s["id"] for s in BUILTIN_SKILLS}
for expected in [
"research", "create-new-skill", "resume", "actions", "reformuler",
"correction", "brainstorm", "plan", "ask", "meeting-note", "livrable",
]:
assert expected in ids
def test_every_skill_has_label_and_description(self):
for skill in BUILTIN_SKILLS:
assert skill["label"]
assert skill["description"]
def test_builtin_prompt_resolves(self, skill_store):
prompt = get_skill_prompt("research", USER)
assert prompt and "RECHERCHE" in prompt
def test_unknown_skill_returns_none(self, skill_store):
assert get_skill_prompt("does-not-exist", USER) is None
def test_list_includes_admin_commands(self, skill_store):
data = list_skills(USER)
command_ids = {c["id"] for c in data["commands"]}
assert {"help", "providers", "provider", "model", "keys"} <= command_ids
class TestUserSkills:
def test_create_and_list(self, skill_store):
skill = create_user_skill(USER, {
"id": "my-skill", "label": "Mon skill", "prompt": "Fais X",
})
assert skill["id"] == "my-skill"
assert skill["custom"] is True
stored = list_user_skills(USER)
assert [s["id"] for s in stored] == ["my-skill"]
assert skill_store.exists()
def test_create_rejects_reserved_id(self, skill_store):
with pytest.raises(ValueError):
create_user_skill(USER, {"id": "research", "label": "x", "prompt": "y"})
def test_create_rejects_duplicate(self, skill_store):
create_user_skill(USER, {"id": "dup", "label": "x", "prompt": "y"})
with pytest.raises(ValueError):
create_user_skill(USER, {"id": "dup", "label": "x", "prompt": "y"})
def test_create_rejects_bad_id(self, skill_store):
with pytest.raises(ValueError):
create_user_skill(USER, {"id": "Bad ID!", "label": "x", "prompt": "y"})
def test_create_requires_prompt(self, skill_store):
with pytest.raises(ValueError):
create_user_skill(USER, {"id": "noprompt", "label": "x", "prompt": ""})
def test_user_skill_prompt_resolves(self, skill_store):
create_user_skill(USER, {"id": "custom", "label": "C", "prompt": "Instruction Z"})
assert get_skill_prompt("custom", USER) == "Instruction Z"
def test_skills_are_per_user(self, skill_store):
create_user_skill(USER, {"id": "only-alice", "label": "A", "prompt": "p"})
assert list_user_skills({"username": "bob"}) == []
def test_delete(self, skill_store):
create_user_skill(USER, {"id": "temp", "label": "T", "prompt": "p"})
assert delete_user_skill(USER, "temp") is True
assert list_user_skills(USER) == []
assert delete_user_skill(USER, "temp") is False
def test_corrupted_store_is_ignored(self, skill_store):
skill_store.write_text("{not json", encoding="utf-8")
assert list_user_skills(USER) == []
# Creating still works (store is rewritten).
create_user_skill(USER, {"id": "recover", "label": "R", "prompt": "p"})
data = json.loads(skill_store.read_text(encoding="utf-8"))
assert data["alice"][0]["id"] == "recover"
# ── Endpoint tests ───────────────────────────────────────────────────────
@pytest.fixture
def skills_client(tmp_path, monkeypatch):
"""TestClient with auth enabled and an isolated data dir."""
import asyncio
import shutil
import tempfile
from fastapi.testclient import TestClient
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
from backend.auth.password import hash_password
users = {
"version": 1,
"users": {
"testuser": {
"id": "testuser-1",
"username": "testuser",
"display_name": "Test User",
"password_hash": hash_password("TestPass123!"),
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
}
},
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
os.chdir(str(tmp))
monkeypatch.setattr(skills_mod, "SKILLS_FILE", Path("data/skills.json"))
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_ADMIN_USER"] = "testuser"
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = str(tmp / "vault")
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.indexer import index
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = TestClient(backend.main.app)
yield client
client.close()
os.chdir(orig_cwd)
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["OBSIGATE_AUTH_ENABLED", "OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD",
"OBSIGATE_WATCHER_ENABLED", "VAULT_1_NAME", "VAULT_1_PATH"]:
os.environ.pop(k, None)
def _token(client):
resp = client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"})
return resp.json()["access_token"]
class TestSkillsEndpoints:
def test_list_requires_auth(self, skills_client):
assert skills_client.get("/api/ai/skills").status_code == 401
def test_list_returns_builtins_and_commands(self, skills_client):
token = _token(skills_client)
resp = skills_client.get("/api/ai/skills", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
ids = {s["id"] for s in data["skills"]}
assert "research" in ids
assert any(c["id"] == "help" for c in data["commands"])
def test_create_and_delete_roundtrip(self, skills_client):
token = _token(skills_client)
headers = {"Authorization": f"Bearer {token}"}
resp = skills_client.post(
"/api/ai/skills",
json={"id": "custom", "label": "Custom", "prompt": "Do it"},
headers=headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["id"] == "custom"
listed = skills_client.get("/api/ai/skills", headers=headers).json()
assert "custom" in {s["id"] for s in listed["skills"]}
deleted = skills_client.delete("/api/ai/skills/custom", headers=headers)
assert deleted.status_code == 200
def test_create_invalid_id_returns_400(self, skills_client):
token = _token(skills_client)
resp = skills_client.post(
"/api/ai/skills",
json={"id": "Bad!", "label": "x", "prompt": "y"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 400
def test_delete_unknown_returns_404(self, skills_client):
token = _token(skills_client)
resp = skills_client.delete(
"/api/ai/skills/unknown", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 404