feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
This commit is contained in:
@@ -916,16 +916,25 @@ async function main() {
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── 13. Session management ──
|
||||
await test("sessions persist and can be reopened and deleted", () => {
|
||||
// ── 13. Session management (#95/#96 — persistent backend history) ──
|
||||
await test("sessions persist and can be reopened and deleted", async () => {
|
||||
localStorage.clear();
|
||||
// Mock server to avoid network calls in tests
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/ai/bookslm/history")) {
|
||||
if (u.includes("PUT")) return { ok: true, status: 200, json: async () => ({}) };
|
||||
return { ok: true, status: 200, json: async () => ({ sessions: [] }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._panel = document.createElement("div");
|
||||
b._mode = MODE.DIRECTORY;
|
||||
b._vault = "VaultSessions";
|
||||
b._directory = "notes";
|
||||
b._loadHistory();
|
||||
assert.equal(b._sessions.length, 1, "one session created on first load");
|
||||
await b._loadHistory();
|
||||
assert.ok(b._sessions.length >= 1, "at least one session on first load (local or server)");
|
||||
|
||||
b._messages.push({ role: "user", content: "Première question" });
|
||||
b._messages.push({ role: "assistant", content: "Réponse" });
|
||||
@@ -938,7 +947,7 @@ async function main() {
|
||||
b._messages.push({ role: "user", content: "Deuxième" });
|
||||
b._saveHistory();
|
||||
|
||||
b._loadSession(firstId);
|
||||
await b._loadSession(firstId);
|
||||
assert.equal(b._messages.length, 2, "previous session messages restored");
|
||||
assert.equal(b._messages[0].content, "Première question");
|
||||
const first = b._sessions.find((s) => s.id === firstId);
|
||||
@@ -979,16 +988,26 @@ async function main() {
|
||||
b.close();
|
||||
});
|
||||
|
||||
await test("legacy single-conversation history is migrated", () => {
|
||||
await test("legacy single-conversation history is migrated", async () => {
|
||||
localStorage.clear();
|
||||
// Mock server to avoid network calls in tests
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/ai/bookslm/history")) {
|
||||
if (u.includes("PUT")) return { ok: true, status: 200, json: async () => ({}) };
|
||||
return { ok: true, status: 200, json: async () => ({ sessions: [] }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._mode = MODE.GENERAL;
|
||||
localStorage.setItem(
|
||||
b._legacyHistoryKey(),
|
||||
JSON.stringify([{ role: "user", content: "Ancienne conversation" }])
|
||||
);
|
||||
b._loadHistory();
|
||||
await b._loadHistory();
|
||||
assert.equal(b._sessions.length, 1, "legacy history imported as a session");
|
||||
assert.ok(b._messages.length >= 1, "messages from legacy session available");
|
||||
assert.equal(b._messages[0].content, "Ancienne conversation");
|
||||
});
|
||||
|
||||
|
||||
@@ -1131,3 +1131,243 @@ class TestToolEventSources:
|
||||
assert data["sources"] == [{"title": "T", "url": "https://exemple.fr/t"}]
|
||||
# Non-web tools keep the key, empty, so the UI never guesses.
|
||||
assert _json.loads(_tool_event_sse(self._record("read_file", {})).split("data: ", 1)[1])["sources"] == []
|
||||
|
||||
|
||||
class TestAIGatewayHistoryStore:
|
||||
"""Unit tests for backend.ai_history persistence helpers."""
|
||||
|
||||
def test_upsert_roundtrip_summary(self, tmp_path, monkeypatch):
|
||||
from backend import ai_history
|
||||
|
||||
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
|
||||
session = {
|
||||
"id": "s-abc",
|
||||
"title": "Titre",
|
||||
"mode": "directory",
|
||||
"vault": "V",
|
||||
"directory": "dossier",
|
||||
"documents": [],
|
||||
"context": "directory-V_dossier",
|
||||
"createdAt": 10,
|
||||
"updatedAt": 20,
|
||||
"messages": [{"role": "user", "content": "Coucou"}, {"role": "assistant", "content": "Hey"}],
|
||||
}
|
||||
assert ai_history.upsert_session("alice", session) is not None
|
||||
assert ai_history.upsert_session("bob", session) is not None # isolated per user store
|
||||
|
||||
summaries = ai_history.list_sessions("alice")
|
||||
assert len(summaries) == 1
|
||||
assert summaries[0]["message_count"] == 2
|
||||
assert summaries[0]["preview"] == "Hey"
|
||||
assert "messages" not in summaries[0]
|
||||
|
||||
assert len(ai_history.list_sessions("bob")) == 1
|
||||
assert ai_history.list_sessions("carol") == []
|
||||
|
||||
full = ai_history.get_session("alice", "s-abc")
|
||||
assert full["messages"][0]["content"] == "Coucou"
|
||||
assert ai_history.get_session("alice", "s-missing") is None
|
||||
|
||||
assert ai_history.delete_session("alice", "s-abc") is True
|
||||
assert ai_history.delete_session("alice", "s-abc") is False
|
||||
assert ai_history.get_session("alice", "s-abc") is None
|
||||
assert len(ai_history.list_sessions("bob")) == 1 # bob unaffected
|
||||
|
||||
def test_update_replaces_and_sorts(self, tmp_path, monkeypatch):
|
||||
from backend import ai_history
|
||||
|
||||
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
|
||||
ai_history.upsert_session("u", {"id": "s-1", "title": "Première", "updatedAt": 1, "messages": []})
|
||||
ai_history.upsert_session("u", {"id": "s-2", "title": "Seconde", "updatedAt": 2, "messages": []})
|
||||
ai_history.upsert_session("u", {"id": "s-1", "title": "Première MAJ", "updatedAt": 3, "messages": []})
|
||||
|
||||
titles = [s["title"] for s in ai_history.list_sessions("u")]
|
||||
assert titles == ["Première MAJ", "Seconde"]
|
||||
assert len(ai_history.list_sessions("u")) == 2
|
||||
|
||||
def test_max_sessions_cap(self, tmp_path, monkeypatch):
|
||||
from backend import ai_history
|
||||
|
||||
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
|
||||
monkeypatch.setattr(ai_history, "MAX_SESSIONS", 3)
|
||||
for i in range(5):
|
||||
ai_history.upsert_session("u", {"id": f"s-{i}", "title": f"S{i}", "updatedAt": i, "messages": []})
|
||||
|
||||
ids = [s["id"] for s in ai_history.list_sessions("u")]
|
||||
assert len(ids) == 3
|
||||
assert ids == ["s-4", "s-3", "s-2"] # most recent kept
|
||||
|
||||
def test_invalid_inputs(self, tmp_path, monkeypatch):
|
||||
from backend import ai_history
|
||||
|
||||
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
|
||||
assert ai_history.upsert_session("", {}) is None
|
||||
assert ai_history.upsert_session("u", {"id": ""}) is None
|
||||
assert ai_history.get_session("", "s-x") is None
|
||||
assert ai_history.delete_session("", "s-x") is False
|
||||
assert list_sessions_for_missing_user_is_empty(tmp_path)
|
||||
|
||||
|
||||
def list_sessions_for_missing_user_is_empty(tmp_path):
|
||||
# Local wrapper keeps the pytest node independent of the fixture chdir.
|
||||
from backend import ai_history
|
||||
|
||||
old = ai_history.AI_HISTORY_DIR
|
||||
ai_history.AI_HISTORY_DIR = tmp_path / "empty"
|
||||
try:
|
||||
return ai_history.list_sessions("ghost") == []
|
||||
finally:
|
||||
ai_history.AI_HISTORY_DIR = old
|
||||
|
||||
def _session_payload(self, session_id="s-test1", title="Ma conversation"):
|
||||
return {
|
||||
"id": session_id,
|
||||
"title": title,
|
||||
"mode": "general",
|
||||
"vault": None,
|
||||
"directory": "",
|
||||
"documents": [],
|
||||
"context": "general",
|
||||
"createdAt": 1700000000000,
|
||||
"updatedAt": 1700000001000,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Bonjour"},
|
||||
{"role": "assistant", "content": "Salut !"},
|
||||
],
|
||||
}
|
||||
|
||||
def test_history_requires_auth(self, bookslm_client):
|
||||
"""The history endpoints require authentication."""
|
||||
resp = bookslm_client.get("/api/ai/bookslm/history")
|
||||
assert resp.status_code == 401
|
||||
resp = bookslm_client.put(
|
||||
"/api/ai/bookslm/history/s-x",
|
||||
json=self._session_payload(),
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_upsert_then_list_then_get(self, bookslm_client):
|
||||
"""PUT /history/{id} persists, GET /history lists a summary and GET /history/{id} returns the conversation."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = bookslm_client.put(
|
||||
"/api/ai/bookslm/history/s-test1",
|
||||
json=self._session_payload(),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "s-test1"
|
||||
|
||||
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
|
||||
assert len(listing["sessions"]) == 1
|
||||
summary = listing["sessions"][0]
|
||||
assert summary["id"] == "s-test1"
|
||||
assert summary["title"] == "Ma conversation"
|
||||
assert summary["message_count"] == 2
|
||||
assert "messages" not in summary
|
||||
|
||||
full = bookslm_client.get("/api/ai/bookslm/history/s-test1", headers=headers).json()
|
||||
assert full["messages"][0]["content"] == "Bonjour"
|
||||
assert full["context"] == "general"
|
||||
|
||||
def test_update_replaces_session(self, bookslm_client):
|
||||
"""PUT with the same id replaces the stored conversation."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
bookslm_client.put(
|
||||
"/api/ai/bookslm/history/s-upd",
|
||||
json=self._session_payload("s-upd", "Avant"),
|
||||
headers=headers,
|
||||
)
|
||||
payload = self._session_payload("s-upd", "Après")
|
||||
resp = bookslm_client.put(
|
||||
"/api/ai/bookslm/history/s-upd",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.json()["title"] == "Après"
|
||||
|
||||
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
|
||||
assert len(listing["sessions"]) == 1
|
||||
assert listing["sessions"][0]["title"] == "Après"
|
||||
|
||||
def test_list_is_sorted_by_updated_at(self, bookslm_client):
|
||||
"""Sessions are listed most recently updated first."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
old = self._session_payload("s-old", "Ancienne")
|
||||
old["updatedAt"] = 1000
|
||||
new = self._session_payload("s-new", "Récente")
|
||||
new["updatedAt"] = 5000
|
||||
bookslm_client.put("/api/ai/bookslm/history/s-old", json=old, headers=headers)
|
||||
bookslm_client.put("/api/ai/bookslm/history/s-new", json=new, headers=headers)
|
||||
|
||||
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
|
||||
ids = [s["id"] for s in listing["sessions"]]
|
||||
assert ids == ["s-new", "s-old"]
|
||||
|
||||
def test_get_unknown_session_returns_404(self, bookslm_client):
|
||||
"""GET /history/{id} on an unknown id returns 404."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.get(
|
||||
"/api/ai/bookslm/history/s-unknown",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_removes_session(self, bookslm_client):
|
||||
"""DELETE /history/{id} removes the conversation for the current user."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
bookslm_client.put(
|
||||
"/api/ai/bookslm/history/s-del",
|
||||
json=self._session_payload("s-del"),
|
||||
headers=headers,
|
||||
)
|
||||
resp = bookslm_client.delete("/api/ai/bookslm/history/s-del", headers=headers)
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
|
||||
assert listing["sessions"] == []
|
||||
assert bookslm_client.get("/api/ai/bookslm/history/s-del", headers=headers).status_code == 404
|
||||
|
||||
def test_history_is_isolated_per_user(self, bookslm_client):
|
||||
"""A session belongs to the user who created it, not to others."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
bookslm_client.put(
|
||||
"/api/ai/bookslm/history/s-private",
|
||||
json=self._session_payload("s-private"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Provision a second user so isolation can be checked.
|
||||
from backend.auth.password import hash_password
|
||||
|
||||
db = json.loads(Path("data/users.json").read_text(encoding="utf-8"))
|
||||
db["users"]["bob"] = {
|
||||
"id": "bob-1",
|
||||
"username": "bob",
|
||||
"display_name": "Bob",
|
||||
"password_hash": hash_password("BobPass123!"),
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
Path("data/users.json").write_text(json.dumps(db), encoding="utf-8")
|
||||
|
||||
other_token, _ = _login_bookslm(bookslm_client, username="bob", password="BobPass123!")
|
||||
resp = bookslm_client.get(
|
||||
"/api/ai/bookslm/history/s-private",
|
||||
headers={"Authorization": f"Bearer {other_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert bookslm_client.get(
|
||||
"/api/ai/bookslm/history",
|
||||
headers={"Authorization": f"Bearer {other_token}"},
|
||||
).json()["sessions"] == []
|
||||
|
||||
Reference in New Issue
Block a user