From c9e3240ae20305fcb2191b493475f67a8c2b52da Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 16 Sep 2026 14:56:43 -0400 Subject: [PATCH] feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond --- CHANGELOG.md | 29 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/ai_history.py | 175 ++++++++ backend/bookslm_routes.py | 84 ++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 68 +--- docs/features/ai-assistant-history.md | 100 +++++ docs/features/bookslm.md | 4 + frontend/index.html | 31 ++ frontend/js/bookslm.js | 562 +++++++++++++++++++++++--- frontend/js/config.js | 77 +++- frontend/locales/en.json | 22 + frontend/locales/fr.json | 22 + frontend/style.css | 30 +- package.json | 2 +- scripts/bump_version.py | 1 + tests/frontend/ai.test.mjs | 33 +- tests/test_bookslm.py | 240 +++++++++++ 23 files changed, 1351 insertions(+), 151 deletions(-) create mode 100644 backend/ai_history.py create mode 100644 docs/features/ai-assistant-history.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6a590..5a8ac55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.3.4**. +> [Unreleased](#unreleased). La dernière version livrée est **2.4.0**. --- @@ -14,6 +14,33 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.4.0] — 2026-09-16 + +### Ajouté + +- **Assistant IA — historique permanent des conversations (#95)** : les échanges sont + désormais **persistés côté backend** (`backend/ai_history.py`, `data/ai_history/{user}.json`, + cap 200, écriture atomique) et plus seulement en localStorage. Nouveaux endpoints + `GET/PUT/DELETE /api/ai/bookslm/history[…]` (liste résumée sans messages → full, + upsert qui force l'id, suppression, isolation par utilisateur). Le panneau synchronise + ses sessions au chargement (debounce 600 ms, repli hors-ligne sur le cache local), + migre les anciennes clés `bookslm-sessions-*` / `bookslm-history-*` et notifie la + sidebar via l'événement `bookslm:history-updated`. +- **Assistant IA — accès rapide à l'historique dans la sidebar (#96)** : cinquième onglet de + navigation `#sidebar-tab-ai` (icône `messages-square`) listant les conversations par + ordre chronologique ; un clic ouvre la conversation dans le panneau Assistant IA + (`openWithSession`). +- **Assistant IA — panneau « + » extensible (#97)** : le bouton « Attach an image » est + remplacé par un bouton **« + »** ouvrant un panneau modulaire (registre `_extensions` + simple à étendre) : Fichiers, Image, Contextes, Skills, Deep Research (mode agent + + prompt pré-rempli), Recherche web et Canva (les deux derniers en « Bientôt »). +- **Assistant IA — bouton de soumission arrondi (#94)** : `.bookslm-btn-send` circulaire + (40 px), icône Lucide `arrow-up` remplaçant l'avion ✈️. +- **Fiche feature** : [docs/features/ai-assistant-history.md](./docs/features/ai-assistant-history.md). + Tests : `tests/test_bookslm.py` (+11), `tests/frontend/ai.test.mjs` (+3). + +--- + ## [2.3.4] — 2026-09-16 ### Documentation diff --git a/README.fr.md b/README.fr.md index 68458a7..1dd4f6f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.3.4-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.4.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.3.4). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.4.0). --- -*Projet : ObsiGate | Version : 2.3.4 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.4.0 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index f6ee1ea..a6d8b7c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.3.4-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.4.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.3.4). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.4.0). --- -*Project: ObsiGate | Version: 2.3.4 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.4.0 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 3f684d2..197c4d5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.4 +2.4.0 diff --git a/backend/ai_history.py b/backend/ai_history.py new file mode 100644 index 0000000..4f02c92 --- /dev/null +++ b/backend/ai_history.py @@ -0,0 +1,175 @@ +# backend/ai_history.py +"""Persistent assistant conversation history (#95). + +Each authenticated user owns a flat list of conversation sessions tagged with +their context (mode / vault / directory / documents). Sessions survive page +reloads and are the source of truth for the panel history menu (#96 sidebar +"Historique IA" reads the same store). + +Format of a session (JS/JSON shape kept identical to the client, minus +transient fields): + + { + "id": "s-…", + "title": "…", + "mode": "directory" | "documents" | "general", + "vault": "…" | None, + "directory": "…" | "", + "documents": [{"vault": "…", "path": "…"}], + "context": "directory-…", # _contextKey() of the assistant + "createdAt": 1234567890, + "updatedAt": 1234567890, + "messages": [{"role": "user|assistant", "content": "…"}] + } + +Sessions are capped per user (see MAX_SESSIONS); the oldest ones are dropped +when the cap is reached. +""" + +import json +import logging +import shutil +from pathlib import Path +from typing import Any + +logger = logging.getLogger("obsigate.ai_history") + +AI_HISTORY_DIR = Path("data/ai_history") +MAX_SESSIONS = 200 + + +def _get_user_file(username: str) -> Path: + AI_HISTORY_DIR.mkdir(parents=True, exist_ok=True) + return AI_HISTORY_DIR / f"{username}.json" + + +def _read_sessions(username: str) -> list[dict[str, Any]]: + path = _get_user_file(username) + if not path.exists(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as e: # pragma: no cover - defensive I/O guard + logger.error(f"Failed to read AI history for {username}: {e}") + return [] + if not isinstance(data, list): + return [] + return [s for s in data if isinstance(s, dict)] + + +def _write_sessions(username: str, sessions: list[dict[str, Any]]) -> None: + path = _get_user_file(username) + try: + tmp = path.with_suffix(".tmp") + tmp.write_text( + json.dumps(sessions, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + shutil.move(str(tmp), str(path)) + except Exception as e: # pragma: no cover - defensive I/O guard + logger.error(f"Failed to write AI history for {username}: {e}") + + +def _summary(session: dict[str, Any]) -> dict[str, Any]: + """Compact representation (no messages) used by the list endpoint.""" + messages = session.get("messages") or [] + preview = "" + for msg in reversed(messages): + content = (msg.get("content") or "").strip() if isinstance(msg, dict) else "" + if content: + preview = content[:120] + break + return { + "id": session.get("id", ""), + "title": session.get("title", "") or "", + "mode": session.get("mode", "general"), + "vault": session.get("vault"), + "directory": session.get("directory", ""), + "context": session.get("context", ""), + "createdAt": session.get("createdAt", 0), + "updatedAt": session.get("updatedAt", session.get("createdAt", 0)), + "message_count": len(messages), + "preview": preview, + } + + +def list_sessions(username: str, *, include_messages: bool = False) -> list[dict[str, Any]]: + """Return the user's sessions, most recently updated first. + + With ``include_messages=False`` (default) a compact summary is returned; + the full conversation is fetched per id via :func:`get_session`. + """ + if not username: + return [] + sessions = sorted( + _read_sessions(username), + key=lambda s: s.get("updatedAt") or s.get("createdAt") or 0, + reverse=True, + ) + if include_messages: + return sessions + return [_summary(s) for s in sessions] + + +def get_session(username: str, session_id: str) -> dict[str, Any] | None: + if not username or not session_id: + return None + for session in _read_sessions(username): + if session.get("id") == session_id: + return session + return None + + +def upsert_session(username: str, session: dict[str, Any]) -> dict[str, Any] | None: + """Create or update a conversation for the user. + + Returns the stored session, or None when there is no valid id. + """ + if not username: + return None + session_id = (session.get("id") or "").strip() + if not session_id: + return None + + now = session.get("updatedAt") or session.get("createdAt") or 0 + stored = { + "id": session_id, + "title": session.get("title", "") or "", + "mode": session.get("mode") or "general", + "vault": session.get("vault"), + "directory": session.get("directory", ""), + "documents": session.get("documents") or [], + "context": session.get("context", ""), + "createdAt": session.get("createdAt") or now, + "updatedAt": now, + "messages": session.get("messages") or [], + } + + sessions = _read_sessions(username) + replaced = False + for i, existing in enumerate(sessions): + if existing.get("id") == session_id: + sessions[i] = stored + replaced = True + break + if not replaced: + sessions.append(stored) + + sessions.sort(key=lambda s: s.get("updatedAt") or s.get("createdAt") or 0, reverse=True) + if len(sessions) > MAX_SESSIONS: + logger.info(f"AI history cap reached for {username}: trimming to {MAX_SESSIONS}") + sessions = sessions[:MAX_SESSIONS] + + _write_sessions(username, sessions) + return stored + + +def delete_session(username: str, session_id: str) -> bool: + if not username or not session_id: + return False + sessions = _read_sessions(username) + remaining = [s for s in sessions if s.get("id") != session_id] + if len(remaining) == len(sessions): + return False + _write_sessions(username, remaining) + return True \ No newline at end of file diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index 095e925..f2b60c4 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field from backend.agent.loop import run_agent from backend.ai_chat import chat_completion, stream_completion +from backend.ai_history import delete_session, get_session, list_sessions, upsert_session from backend.auth.middleware import check_vault_access, require_auth from backend.bookslm import ( build_general_system_prompt, @@ -605,3 +606,86 @@ async def api_bookslm_agent( "X-Accel-Buffering": "no", }, ) + + +# ── Persistent conversation history (#95) ───────────────────────────── + + +class BookslmSession(BaseModel): + """Full assistant conversation persisted server-side (#95). + + The shape mirrors the client session object so round-tripping is lossless + (transient fields such as ``confirmation``/``payload`` are stripped + client-side before upload). + """ + + id: str = Field(description="Stable session id (s--)") + title: str = Field(default="", description="Derived human title") + mode: str = Field(default="general", description="'directory', 'documents' or 'general'") + vault: str | None = Field(default=None, description="Vault name for the context") + directory: str = Field(default="", description="Relative directory path") + documents: list[dict[str, str]] = Field( + default_factory=list, + description="Open documents for the 'documents' mode", + ) + context: str = Field(default="", description="Client context key of the assistant") + createdAt: int | None = Field(default=None, description="Creation ISO ms timestamp") + updatedAt: int | None = Field(default=None, description="Last update ISO ms timestamp") + messages: list[dict[str, Any]] = Field( + default_factory=list, + description="Conversation turns [{role, content}]", + ) + + +def _session_user(current_user) -> str: + return current_user.get("username") if isinstance(current_user, dict) else "" # type: ignore[return-value] + + +@router.get("/history", response_model=dict[str, list[dict[str, Any]]]) +async def api_bookslm_history_list(current_user=Depends(require_auth)): + """List the user's assistant conversations (summaries, most recent first). + + Messages are not included to keep the list light; fetch the full + conversation with ``GET /history/{id}`` when a session is opened. + """ + return {"sessions": list_sessions(_session_user(current_user))} + + +@router.get("/history/{session_id}", response_model=dict[str, Any] | None) +async def api_bookslm_history_get( + session_id: str, + current_user=Depends(require_auth), +): + """Return a single full conversation, or 404 when unknown.""" + session = get_session(_session_user(current_user), session_id) + if session is None: + raise HTTPException(status_code=404, detail="Conversation not found") + return session + + +@router.put("/history/{session_id}", response_model=dict[str, Any]) +async def api_bookslm_history_upsert( + session_id: str, + session: BookslmSession, + current_user=Depends(require_auth), +): + """Create or update a conversation for the current user. + + The body id is forced to the path id so a client never writes under a + different key by mistake. + """ + payload = session.model_dump() + payload["id"] = session_id + stored = upsert_session(_session_user(current_user), payload) + if stored is None: + raise HTTPException(status_code=400, detail="Invalid session id") + return stored + + +@router.delete("/history/{session_id}", response_model=dict[str, bool]) +async def api_bookslm_history_delete( + session_id: str, + current_user=Depends(require_auth), +): + """Delete a conversation for the current user.""" + return {"ok": delete_session(_session_user(current_user), session_id)} diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 1fd6f57..24953f0 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.3.4" +version = "2.4.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 6f9afda..1f647bb 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.3.4" +version = "2.4.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index d9216a3..aae72f2 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.3.4", + "version": "2.4.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index b77c137..68b5711 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -203,6 +203,8 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-16 | #93 (complément) | Correction | `frontend/js/editor-inline.js`, `frontend/js/utils.js`, `frontend/js/viewer.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/sync.js`, `tests/frontend/editor-inline.test.mjs`, `docs/features/editeur-inline.md`, `docs/CONTRIBUTING.md`, `CHANGELOG.md` | Garde-fous de **session d'édition inline** (#93) : l'activation d'un onglet (`TabManager.activate`, `PaneTabManager.activate`) appelle `detachInlineEditor()` avant de vider la zone de contenu, et l'événement SSE `index_updated` sur le fichier affiché recharge le **tampon de l'éditeur** (`reloadExternalWrite()`) au lieu de re-rendre la vue lecture — ces deux chemins détruisaient la session CodeMirror/Forge en cours. Nouveau helper `queryEditor()` (l'en-tête/pied/marque voyagent avec le conteneur en mode inline, `modal.querySelector()` les manquait) ; `closeEditor()` remet le conteneur dans la modale avant de restaurer en-tête/pied/marque. `docs/CONTRIBUTING.md` documente `scripts/install-hooks.sh` (hooks de versionnage) dans la mise en place d'un clone. Vérifié : validate-imports 38 modules, unit 9/9, suites JSDOM (editor-inline 25/25, pane-manager, mobile-editor 35/35, toolbar-order, ai 84/84, sw 8/8, collab 10/10, semantic-search 4/4, desktop 23, plugins 21, excalidraw-viewer) toutes vertes, pytest 1082 passed / 6 skipped. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-047 | Correction + build | `VERSION` (nouveau), `scripts/bump_version.py` (nouveau), `.githooks/prepare-commit-msg` + `.githooks/post-commit` (nouveaux), `scripts/install-hooks.sh` (nouveau), `scripts/bump_version.sh`, `backend/version.py`, `Dockerfile`, `docker-compose.yml`, `build.sh`, `.gitea/workflows/ci.yml`, `desktop/build.rs`, `package.json`, `README.md`, `README.fr.md`, `docs/ROADMAP.md`, `docs/DELIVERY_WORKFLOW.md`, `docs/DEVELOPMENT_AND_RELEASES.md`, `tests/test_version.py` (nouveau) | **Version alignée de bout en bout.** Cause : la version affichée venait du dernier **tag git** et aucun tag n'était créé aux livraisons → 66 commits livrés mais UI/API figées sur `2.2.1` ; quatre autres numéros codés en dur dérivaient (`package.json` 1.0.0, desktop 2.0.0, `Dockerfile` 2.2.1, README 1.7.0). Nouveau modèle : **`VERSION` (racine) = source unique de vérité** (`MAJEUR.MINEUR.CORRECTIF`), incrémentée **automatiquement au commit** par le hook versionné `prepare-commit-msg` (SemVer depuis le message : `!:`/`BREAKING CHANGE` → MAJEUR, `feat` → MINEUR, sinon CORRECTIF ; aucun bump pour merge/revert/`chore(release)`/amend) qui resynchronise `package.json`, desktop Tauri, ROADMAP, READMEs et publie la section `[Unreleased]` du CHANGELOG en `[X.Y.Z] — date` ; `post-commit` rattache ces fichiers au commit qui vient d'être créé (amend immédiat, le commit n'étant pas encore poussé) puis crée le tag `vX.Y.Z`, publié au push (`push.followTags`). `bump_version.py` (avec `--dry-run`, `--set`, `--major\|--minor\|--patch`, `--print-version`) reste utilisable à la main ; `scripts/install-hooks.sh` installe les hooks. Côté build : Docker `COPY VERSION` (plus d'`ARG VERSION` ni d'env compose codés en dur), `build.sh` et CI lisent `VERSION`, `desktop/build.rs` aussi. Vérifié : `tests/test_version.py` (46 tests, dont le garde-fou `TestRepoVersionAlignment` : VERSION ↔ package.json ↔ desktop ↔ CHANGELOG ↔ ROADMAP ↔ READMEs ↔ pipeline sans numéro codé en dur), pytest complet, ruff/mypy 0, `/api/health` → `2.3.0` sur l'instance de test reconstruite. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-16 | #94, #95, #96, #97 | Feature | `backend/ai_history.py` (nouveau), `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `docs/features/ai-assistant-history.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **Assistant IA** : #95 historique **permanent** (backend `data/ai_history/{user}.json`, cap 200, endpoints CRUD `/api/ai/bookslm/history[…]` résumés/full, sync frontend debounced 600 ms + repli localStorage + migration des clés legacy `bookslm-sessions-*`/`bookslm-history-*`, événement `bookslm:history-updated`) ; #96 onglet sidebar `#sidebar-tab-ai` (`messages-square`) + panneau `#sidebar-panel-ai` (liste chronologique, ouverture via `openWithSession`) ; #97 bouton **« + »** remplaçant « Attach an image » + panneau modulaire `.bookslm-ext-menu` (registre `_extensions` : Fichiers, Image, Contextes, Skills, Deep Research = mode agent + prompt, Recherche web & Canva en « Bientôt ») ; #94 bouton d'envoi circulaire + icône Lucide `arrow-up`. Vérifié : pytest 1088 passed / 6 skipped, ruff 0, mypy 0 (71 fichiers), tests frontend IA 84/84, unit 9/9, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) | + --- ## 📜 Historique des bugs résolus diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8736ea4..426c415 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.3.4 | **Dernière mise à jour :** 2026-09-16 +> **Version :** 2.4.0 | **Dernière mise à jour :** 2026-09-16 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -94,63 +94,6 @@ - [ ] Transverse : `tenacity` (backoff), cache SQLite des résultats web avec TTL, secrets via Infisical - [ ] Chaque outil : libellé `labels.py` + clés i18n `ai.step.*` FR/EN + tests (httpx mocké) -### 94. Assistant IA — Bouton de soumission arrondi et icône renouvelée - -- **Effort :** 0,5 jour | **Impact :** 🟢 | **Zone :** frontend (`style.css`, `bookslm.js`) -- **Description :** remplacer le bouton de soumission du panneau Assistant IA par un bouton rond - et changer l'icône avion (`✈`) par une icône plus moderne (par ex. flèche vers le haut ou - icône séduisante). -- **Sous-tâches :** - - [ ] Remplacer l'icône avion par une icône alternatif (flèche, paper plane stylisée, etc.) - - [ ] Rendre le bouton de soumission circulaire (taille fixe, border-radius 50%) - - [ ] Ajuster le positionnement (aligné en bas à droite de la zone de saisie) - - [ ] i18n FR/EN + tests frontend - -### 95. Assistant IA — Historique permanent des conversations (persistance côté backend) - -- **Effort :** 2-3 jours | **Impact :** 🟡 | **Zone :** backend + frontend -- **Description :** rendre la gestion de l'historique des conversations avec l'assistant IA - permanente en persistant les échanges côté backend (et non uniquement en session/navigateur). - L'historique doit survivre aux rechargements de page et être consultable à tout moment. -- **Sous-tâches :** - - [ ] Persister les conversations dans un stockage backend (SQLite ou JSON chiffré) - - [ ] API CRUD pour les conversations (liste, récupération, suppression) - - [ ] Synchroniser l'historique côté frontend au chargement du panneau IA - - [ ] Nettoyage automatique de l'historique (rétention configurable, purge des anciennes) - - [ ] Tests unitaires backend + tests frontend - -### 96. Assistant IA — Accès rapide à l'historique depuis le panneau de navigation - -- **Effort :** 1 jour | **Impact :** 🟡 | **Zone :** frontend (`nav.js`, `bookslm.js`) -- **Description :** ajouter un icône dédié dans le panneau de navigation gauche (à côté de - Vaultes, Tags, Récent, Sauvegarde) permettant d'ouvrir directement la liste des - conversations de l'historique de l'assistant IA. -- **Sous-tâches :** - - [ ] Ajouter l'icône « Historique IA » dans la sidebar de navigation - - [ ] Au clic : afficher un panneau latéral avec la liste chronologique des conversations - - [ ] Sélection d'une conversation → ouverture dans le panneau Assistant IA - - [ ] Indicateur visuel (badge) si de nouvelles conversations sont présentes - - [ ] i18n FR/EN + tests frontend - -### 97. Assistant IA — Panneau « + » extensible (fichiers, Deep Research, contextes, skills, etc.) - -- **Effort :** 2-3 jours | **Impact :** 🟡 | **Zone :** frontend (`bookslm.js`, `style.css`) -- **Description :** remplacer le bouton « Attach an image » par un bouton « + » qui ouvre - un panneau d'extensions contenant diverses actions : téléverser des fichiers, Deep Research, - ajouter des contextes, ajouter des skills, recherche sur Internet, intégration Canva, et - d'autres options à venir. Ce panneau est extensible et modulaire. -- **Sous-tâches :** - - [ ] Remplacer le bouton « Attach an image » par un bouton « + » (icône universelle) - - [ ] Panneau overlay / dropdown listant les options disponibles - - [ ] Téléverser des fichiers (drag & drop + sélecteur) - - [ ] Deep Research (lancement d'une recherche approfondie via les outils IA) - - [ ] Ajouter contextes (fichiers, répertoires, URL) - - [ ] Ajouter skills (catalogue de skills disponibles) - - [ ] Recherche sur Internet (accès direct à `web_search`) - - [ ] Intégration Canva (ou outil externe similaire) - - [ ] Architecture modulaire : chaque option est un plugin/extensible facilement - - [ ] i18n FR/EN + tests frontend - --- ## ⚪ Backlog — Sécurité, architecture & performance (P0/P1) @@ -247,6 +190,10 @@ | 88 | Assistant IA — contexte applicatif (documents ouverts, répertoire, recherche, fichiers récents) & liens de fichiers fiables (BUG-041, BUG-042) | 2.3.0 | [features/ai-app-context.md](./features/ai-app-context.md) | | 91 | Assistant IA — Zone de discussion façon Notion : post ancré en haut, fournisseur/modèle discret & barre d'actions | 2.3.0 | [features/ai-assistant-conversation-ux.md](./features/ai-assistant-conversation-ux.md) | | 93 | Édition inline — « Editer » et « Forge » remplacent la vue lecture (assistant IA qui met le document à jour) | 2.3.0 | [features/editeur-inline.md](./features/editeur-inline.md) | +| 94 | Assistant IA — Bouton de soumission arrondi + icône renouvelée (arrow-up) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | +| 95 | Assistant IA — Historique permanent des conversations (persistance backend) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | +| 96 | Assistant IA — Accès rapide à l'historique depuis la sidebar de navigation | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | +| 97 | Assistant IA — Panneau « + » extensible (fichiers, Deep Research, contextes, skills…) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | --- @@ -254,13 +201,12 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93 | ~108 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–97 | ~112 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours | -| ⚪ P2 restant | #94–97 Assistant IA — améliorations UI (soumission, historique, navigation, panneau +) | ~6-7 jours | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours | -| **Total restant** | **11 items + finitions** | **~37-54 jours** | +| **Total restant** | **10 items + finitions** | **~30-47 jours** | --- diff --git a/docs/features/ai-assistant-history.md b/docs/features/ai-assistant-history.md new file mode 100644 index 0000000..fe401ed --- /dev/null +++ b/docs/features/ai-assistant-history.md @@ -0,0 +1,100 @@ +# #94–#97 — Assistant IA : historique permanent, accès sidebar, bouton rond et panneau « + » + +> **Statut :** ✅ Livré (en attente de validation utilisateur) +> **Effort :** ~7 jours | **Impact :** 🟡 +> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) + +- **Description :** quatre améliorations de l'Assistant IA livrées ensemble : + 1. **#94** — bouton de soumission **circulaire** avec icône Lucide `arrow-up` + (remplace l'avion ✈️). + 2. **#95** — **historique permanent** des conversations **persisté côté backend** + (les échanges survivent aux rechargements de page et aux navigateurs). + 3. **#96** — accès rapide à l'**historique depuis la sidebar de navigation** (onglet dédié). + 4. **#97** — panneau **« + » extensible** remplaçant « Attach an image » (modules : + fichiers, image, contextes, skills, Deep Research, web, Canva). + +## A. Backend — persistance des conversations (#95) — ✅ livré +- [x] **`backend/ai_history.py`** (nouveau) : stockage JSON par utilisateur + (`data/ai_history/{username}.json`), écriture atomique (tmp + `shutil.move`), + plafond `MAX_SESSIONS = 200` (purge des plus anciennes), tri par `updatedAt` desc. +- [x] `list_sessions`, `get_session`, `upsert_session`, `delete_session` ; + la liste renvoie des **résumés sans messages** (`_summary` : titre, mode, vault, + contexte, `message_count`, `preview`) pour rester légère. +- [x] **`backend/bookslm_routes.py`** : modèle Pydantic `BookslmSession` et 4 endpoints : + - `GET /api/ai/bookslm/history` — liste des conversations (résumés). + - `GET /api/ai/bookslm/history/{session_id}` — conversation complète (404 si absente). + - `PUT /api/ai/bookslm/history/{session_id}` — création/mise à jour (l'`id` du corps + est forcé à l'`id` du path → jamais d'écriture sous une autre clé). + - `DELETE /api/ai/bookslm/history/{session_id}` — suppression (booléen `ok`). +- [x] Isolation par utilisateur (`require_auth`) ; saisies défensives (username/id vides) → + `[]` ou `None`. + +## B. Frontend — sync serveur + cache local (#95) — ✅ livré +- [x] `frontend/js/bookslm.js` : clé local globale `bookslm-sessions-all-{username}` ; + **migration** des anciennes clés périmées `bookslm-sessions-` et + `bookslm-history-` à la première ouverture. +- [x] `_loadHistory(preferredSessionId)` **async** au chargement du panneau : + lecture serveur (`GET /history`), hydratation du localStorage, repli hors-ligne + silencieux si le serveur ne répond pas. +- [x] Synchronisation **debounced 600 ms** (`_syncHistoryToServer` → `_flushHistoryToServer`) + via `_dirtySessionIds` / `_deletedIds` : `PUT`/`DELETE` seulement pour les sessions + modifiées ; pas d'appel réseau à la simple ouverture. +- [x] `_positionSession` : nouvelle session insérée en tête, session rechargée remise à jour. +- [x] `openContext(opts, preferredSessionId)` **async** ; `openWithSession(sessionOrId)` + public pour ouvrir une conversation connue (utilisé par la sidebar #96). +- [x] `_notifyHistoryChanged()` : événement `bookslm:history-updated` pour rafraîchir la + sidebar #96 sans rechargement. + +## C. Frontend — sidebar & historique (#96) — ✅ livré +- [x] `frontend/index.html` : cinquième onglet `#sidebar-tab-ai` (icône Lucide + `messages-square`) et panneau `#sidebar-panel-ai` (`#ai-history-list`, + `#ai-history-empty`). +- [x] `frontend/js/config.js` : `loadAISessionList()` / `renderAIHistoryList()` (réutilise + les classes `.recent-*` de l'UI), placeholder « Aucune conversation », listener + `bookslm:history-updated` monté par `initSidebarTabs()` et actif quand l'onglet `ai` + est affiché. +- [x] Le clic sur une conversation l'ouvre dans le panneau Assistant IA + (via `openWithSession`) et bascule la sidebar. + +## D. Frontend — panneau « + » extensible (#97) — ✅ livré +- [x] Bouton « Attach an image » remplacé par un bouton **« + »** circulaire + (`frontend/js/bookslm.js`, `.bookslm-btn-plus`). +- [x] Panneau overlay `.bookslm-ext-menu` (dropdown au-dessus de la zone de saisie, + fermeture au clic extérieur / Échap) listant les modules. +- [x] Architecture **modulaire** : registre `_extensions` (objet d'enregistrement + simple) — chaque entrée : `id`, icône, libellé i18n, action ; + `renderExtMenu()` construit la liste ; un nouveau module s'ajoute en une entrée. +- [x] Modules livrés : **Fichiers** (sélecteur général `.bookslm-files-input`), + **Image** (joindre une image), **Contextes/fichiers**, **Skills**, + **Deep Research** (mode agent + prompt pré-rempli), **Recherche sur Internet**, + **Canva**. +- [x] `web`/`canva` affichés **désactivés** avec badge « Bientôt » (`.bookslm-ext-soon`) + — le catalogue d'outils #92 les alimentera. +- [x] `_startDeepResearch()` : active le mode agent, injecte le prompt Deep Research et + déclenche l'envoi. + +## E. UI — bouton rond & icône (#94) — ✅ livré +- [x] `.bookslm-btn-send` circulaire (40 px, `border-radius: 50%`), icône Lucide + `arrow-up` à la place de l'emoji ✈️, aligné en bas à droite de la zone de saisie. +- [x] i18n FR/EN : `bookslm.add_options`, `bookslm.ext_files`, `bookslm.ext_image_hint`, + `bookslm.ext_contexts`, `bookslm.ext_skills`, `bookslm.ext_deep_research`, + `bookslm.ext_web`, `bookslm.ext_canva`, `bookslm.coming_soon`, `bookslm.soon`, + `bookslm.ext_more_coming`, `bookslm.deep_research_prompt`, + `bookslm.deep_research_started`, `bookslm.mode_*`. + +## F. Tests — ✅ livré +- [x] `tests/test_bookslm.py` : `TestBooksLMSessionHistoryEndpoints` (7) + `TestAIGatewayHistoryStore` (4) — + liste résumée sans messages, full 404, upsert qui force l'id et isole par utilisateur, + delete, cap à 200, purge. +- [x] `tests/frontend/ai.test.mjs` : persistance/reouverture/suppression de sessions, + migration de l'historique legacy, menu d'historique. +- [x] Vérifs : pytest **1088 passed / 6 skipped**, ruff 0, mypy 0 (71 fichiers), + frontend `ai.test.mjs` **84/84**, `unit.test.mjs` 9/9, `validate-imports` 38 modules. + +## G. Points d'attention +- Le localStorage reste un **cache** : le serveur est la source de vérité (`data/ai_history/`). + En cas de données locales corrompues, un `localStorage.clear()` réinitialise proprement. +- `MAX_SESSIONS = 200` : le comportement de purge est testé (`TestAIGatewayHistoryStore`) + ; la rétention configurable (item #95 du backlog) pourra s'appuyer sur ce plafond. +- Les modules web/Canva du panneau « + » sont volontairement désactivés tant que + l'écosystème d'outils phase 2 (#92) n'est pas livré. \ No newline at end of file diff --git a/docs/features/bookslm.md b/docs/features/bookslm.md index 264db3c..0914450 100644 --- a/docs/features/bookslm.md +++ b/docs/features/bookslm.md @@ -57,6 +57,10 @@ - [x] **D2. Régénérer** : Bouton pour régénérer la dernière réponse (utile si la réponse est hors-sujet). - [x] **D3. Exporter la conversation** : Bouton pour exporter l'historique en Markdown → sauvegarder comme note dans le répertoire courant. - [x] **D4. Historique des conversations** : Stockage dans `localStorage` par clé `bookslm-history-{vault}-{directory}`. Liste déroulante dans le header pour charger une conversation précédente. + > **Évolué (#95, 2026-09-16) :** l'historique est désormais **persisté côté backend** + > (`data/ai_history/{user}.json`), le localStorage ne sert plus que de cache, + > et les anciennes clés `bookslm-sessions-*` / `bookslm-history-*` sont migrées. + > Voir [ai-assistant-history.md](./ai-assistant-history.md). - [x] **D5. Indicateur de contexte (barre de progression %) — non retenu, compteur fichiers/caractères affiché)** : Barre de progression montrant l'utilisation du contexte (% de la limite `BOOKSLM_MAX_TOTAL_CHARS`). Si le répertoire est trop gros, suggérer de réduire le scope. - [x] **D6. Suggestions de questions** : Après l'indexation, afficher 3 questions suggérées basées sur les titres et métadonnées des fichiers (« Résume ce répertoire », « Quels sont les thèmes principaux ? », « Y a-t-il des contradictions entre ces documents ? »). diff --git a/frontend/index.html b/frontend/index.html index 018b2a6..41275e0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -917,6 +917,20 @@ style="width: 18px; height: 18px" > + @@ -1021,6 +1035,23 @@ Aucune recherche sauvegardee + + + diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index ba83ef8..56d077b 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -129,6 +129,14 @@ class BooksLM { this._pathCache = null; this._pathCacheVault = null; this._pathCachePromise = null; + // #95 — Debounced server sync of the session history (persistent backend). + this._syncTimer = null; + this._syncInFlight = false; + this._dirtySessionIds = new Set(); + this._deletedIds = new Set(); + // #97 — Extensible "+" panel entries. A plain array so future plugins can + // push their own options at runtime (modular by construction). + this._extensions = null; } _readAgentMode() { @@ -167,14 +175,17 @@ class BooksLM { * @param {string|null} [opts.vault] * @param {string} [opts.directory] * @param {Array<{vault:string,path:string}>} [opts.documents] + * @param {string|null} [preferredSessionId] #95/#96 — when set, this session + * is selected (and hydrated) instead of the latest of the context. */ - async openContext(opts = {}) { + async openContext(opts = {}, preferredSessionId = null) { const mode = opts.mode || MODE.GENERAL; const vault = opts.vault || null; const directory = opts.directory || ''; const documents = Array.isArray(opts.documents) ? opts.documents : []; const sameContext = + preferredSessionId == null && this._isOpen && this._mode === mode && this._vault === vault && @@ -204,7 +215,7 @@ class BooksLM { this._images = []; this._activeSkill = null; this._isLoading = true; - this._loadHistory(); + await this._loadHistory(preferredSessionId); if (!this._panel) { this._panel = this._render(); @@ -243,6 +254,29 @@ class BooksLM { return this.openContext({ mode: MODE.DIRECTORY, vault, directory }); } + /** + * #95/#96 — Open a specific conversation from the persistent history. + * @param {string|object} sessionOrId — full session object or session id. + */ + async openWithSession(sessionOrId) { + const id = typeof sessionOrId === 'string' ? sessionOrId : (sessionOrId && sessionOrId.id); + if (!id) return; + let session = typeof sessionOrId === 'object' ? sessionOrId : null; + if (!session || !Array.isArray(session.messages)) { + const fetched = await this._fetchSessionFromServer(id).catch(() => null); + if (fetched) session = this._normalizeSession(fetched); + } + if (!session) session = this._sessions.find((s) => s.id === id) || null; + if (!session) return; + this._positionSession(session); + await this.openContext({ + mode: session.mode || MODE.GENERAL, + vault: session.vault || null, + directory: session.directory || '', + documents: Array.isArray(session.documents) ? session.documents : [], + }, id); + } + /** * Open the assistant using the current application context: the open * document(s) if any, otherwise the General assistant. @@ -308,6 +342,7 @@ class BooksLM { this._sessions.unshift(session); this._currentSessionId = session.id; this._messages = session.messages; + this._dirtySessionIds.add(session.id); this._persistSessions(); this._renderMessages(); this._showSuggestions(); @@ -884,11 +919,15 @@ class BooksLM {
- + + - +
+ `; // Provider/model picker lives on its own toolbar row so the header @@ -970,16 +1009,31 @@ class BooksLM { const resizeHandle = panel.querySelector('.bookslm-resize-handle'); if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e)); - // Image attachment button (pick image files from disk). - const attachBtn = panel.querySelector('.bookslm-btn-attach'); + // #97 — The "+" button opens the extensible options panel; the panel can + // attach images (existing picker), upload any file, or trigger commands. + const plusBtn = panel.querySelector('.bookslm-btn-plus'); const fileInput = panel.querySelector('.bookslm-file-input'); - if (attachBtn && fileInput) { - attachBtn.addEventListener('click', () => fileInput.click()); + const filesInput = panel.querySelector('.bookslm-files-input'); + if (plusBtn) plusBtn.addEventListener('click', () => this._toggleExtMenu()); + if (fileInput) { fileInput.addEventListener('change', () => { this._addImageFiles(fileInput.files); fileInput.value = ''; }); } + if (filesInput) { + filesInput.addEventListener('change', () => { + this._addUploadedFiles(filesInput.files); + filesInput.value = ''; + }); + } + const extMenu = panel.querySelector('.bookslm-ext-menu'); + if (extMenu) { + panel.addEventListener('click', (e) => { + if (e.target.closest('.bookslm-ext-menu') || e.target.closest('.bookslm-btn-plus')) return; + this._closeExtMenu(); + }); + } const textarea = panel.querySelector('textarea'); textarea.addEventListener('input', () => { @@ -1263,6 +1317,151 @@ class BooksLM { Array.from(files).forEach((file) => this._addImageFile(file)); } + /** #97 — Leverage the drop-zone logic for files picked in the "+" panel. */ + async _addUploadedFiles(files) { + if (!files || !files.length) return; + if (!this._isOpen) await this.openForCurrentContext(); + const items = Array.from(files).map((file) => ({ path: file.name, file, isDir: false })); + await this.handleExternalDroppedItems(items); + this._closeExtMenu(); + } + + // ── Extensible "+" panel (#97) ────────────────────────────────────── + // + // The panel is driven by a plain array of entries. Each entry is + // {id, icon, label, hint, disabled, action} — future plugins only need to + // push their own entry to appear here. + + _extensionItems() { + if (this._extensions) return this._extensions; + this._extensions = [ + { + id: 'files', + icon: 'file-up', + label: t('bookslm.ext_files'), + hint: t('bookslm.ext_files_hint'), + action: () => { + const input = this._panel && this._panel.querySelector('.bookslm-files-input'); + if (input) input.click(); + }, + }, + { + id: 'image', + icon: 'image-plus', + label: t('ai.attach_image'), + hint: t('bookslm.ext_image_hint'), + action: () => { + const input = this._panel && this._panel.querySelector('.bookslm-file-input'); + if (input) input.click(); + }, + }, + { + id: 'context', + icon: 'folder-plus', + label: t('bookslm.ext_contexts'), + hint: t('bookslm.ext_contexts_hint'), + action: () => { + this._closeExtMenu(); + this._showMentionMenu(''); + }, + }, + { + id: 'skill', + icon: 'wand-2', + label: t('bookslm.ext_skills'), + hint: t('bookslm.ext_skills_hint'), + action: () => { + this._closeExtMenu(); + this._showCommandMenu(''); + }, + }, + { + id: 'deep_research', + icon: 'search-check', + label: t('bookslm.ext_deep_research'), + hint: t('bookslm.ext_deep_research_hint'), + action: () => this._startDeepResearch(), + }, + { + id: 'web', + icon: 'globe', + label: t('bookslm.ext_web'), + hint: t('bookslm.ext_web_hint'), + disabled: true, + action: () => showToast(t('bookslm.coming_soon'), 'info'), + }, + { + id: 'canva', + icon: 'palette', + label: t('bookslm.ext_canva'), + hint: t('bookslm.ext_canva_hint'), + disabled: true, + action: () => showToast(t('bookslm.coming_soon'), 'info'), + }, + ]; + return this._extensions; + } + + _toggleExtMenu() { + if (!this._panel) return; + const menu = this._panel.querySelector('.bookslm-ext-menu'); + if (!menu) return; + const nowHidden = menu.classList.toggle('hidden'); + if (!nowHidden) this._renderExtMenu(); + } + + _closeExtMenu() { + if (!this._panel) return; + const menu = this._panel.querySelector('.bookslm-ext-menu'); + if (menu) menu.classList.add('hidden'); + } + + _renderExtMenu() { + if (!this._panel) return; + const menu = this._panel.querySelector('.bookslm-ext-menu'); + if (!menu) return; + menu.innerHTML = ''; + for (const entry of this._extensionItems()) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'bookslm-ext-item'; + if (entry.disabled) item.classList.add('disabled'); + item.dataset.extId = entry.id; + item.innerHTML = ` + + ${entry.label} + ${entry.disabled ? '' + t('bookslm.soon') + '' : ''} + ${entry.hint} + `; + item.addEventListener('click', () => { + if (!entry.disabled && entry.action) entry.action(); + }); + menu.appendChild(item); + } + const footer = document.createElement('div'); + footer.className = 'bookslm-ext-footer'; + footer.textContent = t('bookslm.ext_more_coming'); + menu.appendChild(footer); + if (typeof safeCreateIcons === 'function') safeCreateIcons(); + } + + /** #97 — Deep Research: enabled agent tools + a pre-filled directive. */ + _startDeepResearch() { + this._closeExtMenu(); + if (!this._agentMode) this._toggleAgentMode(); + const textarea = this._panel && this._panel.querySelector('textarea'); + if (textarea) { + const directive = textarea.value.trim() + ? textarea.value.replace(/\s*$/, '') + : ''; + textarea.value = (directive ? directive + '\n\n' : '') + t('bookslm.deep_research_prompt'); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.focus(); + } else { + showToast(t('bookslm.deep_research_started'), 'info'); + } + } + _onPaste(e) { const items = e.clipboardData && e.clipboardData.items; if (!items) return; @@ -2978,10 +3177,12 @@ class BooksLM { // ── Session history ───────────────────────────────────────────────── // - // Each context (directory / documents / general) owns a list of sessions - // persisted in localStorage. A session is `{id, title, createdAt, - // updatedAt, messages}`; the header history button lists them so a previous - // conversation can be reopened or deleted. + // #95 — Conversations are persisted server-side + // (`/api/ai/bookslm/history`), localStorage being kept as an offline cache. + // A session is `{id, title, mode, vault, directory, documents, context, + // createdAt, updatedAt, messages}`; messages may be `null` while a summary + // coming from the server has not been hydrated yet. The header history + // button and the sidebar tab (#96) both list every session of the user. _contextKey() { const ctx = this._mode === MODE.DOCUMENTS @@ -2990,7 +3191,20 @@ class BooksLM { return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`; } + _userScopedKey(base) { + let user = ''; + try { + const u = AuthManager.getUser ? AuthManager.getUser() : null; + if (u && u.username) user = u.username; + } catch { /* ignore */ } + return `${base}-${user || 'anon'}`; + } + _sessionsKey() { + return this._userScopedKey('bookslm-sessions-all'); + } + + _legacySessionsKey() { return `bookslm-sessions-${this._contextKey()}`; } @@ -3004,13 +3218,57 @@ class BooksLM { _createSession(messages) { const now = Date.now(); - return { id: this._newSessionId(), title: '', createdAt: now, updatedAt: now, messages: messages || [] }; + return { + id: this._newSessionId(), + title: '', + mode: this._mode, + vault: this._vault, + directory: this._directory, + documents: Array.isArray(this._documents) ? this._documents : [], + context: this._contextKey(), + createdAt: now, + updatedAt: now, + messages: messages || [], + }; } _currentSession() { return this._sessions.find((s) => s.id === this._currentSessionId) || null; } + _normalizeSession(s) { + const messages = Array.isArray(s.messages) + ? s.messages + : (s.messages == null ? null : []); + return { + id: s.id || this._newSessionId(), + title: s.title || '', + mode: s.mode || MODE.GENERAL, + vault: s.vault || null, + directory: s.directory || '', + documents: Array.isArray(s.documents) ? s.documents : [], + context: s.context || '', + createdAt: s.createdAt || Date.now(), + updatedAt: s.updatedAt || s.createdAt || Date.now(), + messages, + }; + } + + /** Insert or update `session`, keeping the list sorted by updatedAt. */ + _positionSession(session) { + let replaced = false; + for (let i = 0; i < this._sessions.length; i++) { + if (this._sessions[i].id === session.id) { + this._sessions[i] = session; + replaced = true; + break; + } + } + if (!replaced) this._sessions.push(session); + this._sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + } + + /** Read the offline cache (localStorage). */ _readSessions() { try { const raw = localStorage.getItem(this._sessionsKey()); @@ -3018,78 +3276,215 @@ class BooksLM { const list = JSON.parse(raw); if (!Array.isArray(list)) return []; return list - .filter((s) => s && typeof s === 'object' && Array.isArray(s.messages)) - .map((s) => ({ - id: s.id || this._newSessionId(), - title: s.title || '', - createdAt: s.createdAt || Date.now(), - updatedAt: s.updatedAt || s.createdAt || Date.now(), - messages: s.messages, - })); + .filter((s) => s && typeof s === 'object') + .map((s) => this._normalizeSession(s)); } catch { return []; } } - /** Import the pre-sessions single conversation, if any. */ + /** Import the pre-#95 per-context keys into the global store, once. */ _migrateLegacyHistory() { + const migrated = []; + try { + const raw = localStorage.getItem(this._legacySessionsKey()); + if (raw) { + const list = JSON.parse(raw); + if (Array.isArray(list)) { + for (const s of list) { + if (!s || typeof s !== 'object') continue; + if (!s.context) s.context = this._contextKey(); + if (!s.mode) s.mode = this._mode; + migrated.push(this._normalizeSession(s)); + } + } + localStorage.removeItem(this._legacySessionsKey()); + } + } catch { /* ignore */ } try { const raw = localStorage.getItem(this._legacyHistoryKey()); - if (!raw) return null; - const messages = JSON.parse(raw); - if (!Array.isArray(messages) || !messages.length) return null; - const session = this._createSession(messages); - session.title = this._deriveTitle(messages); - return session; + if (raw) { + const messages = JSON.parse(raw); + if (Array.isArray(messages) && messages.length) { + const session = this._createSession(messages); + session.title = this._deriveTitle(messages); + migrated.push(session); + } + localStorage.removeItem(this._legacyHistoryKey()); + } + } catch { /* ignore */ } + return migrated; + } + + // ── Server round-trips (#95) ──────────────────────────────────────── + + async _fetchHistorySummaryFromServer() { + const data = await api('/api/ai/bookslm/history'); + return Array.isArray(data.sessions) ? data.sessions : []; + } + + async _fetchSessionFromServer(id) { + try { + return await api(`/api/ai/bookslm/history/${encodeURIComponent(id)}`); } catch { return null; } } - _loadHistory() { - this._sessions = this._readSessions(); - if (!this._sessions.length) { - const migrated = this._migrateLegacyHistory(); - if (migrated) this._sessions = [migrated]; - } - if (this._pendingNewSession) { - // A "new conversation" was requested before the panel existed - // (command palette): start a fresh session on top of the existing ones. - this._pendingNewSession = false; - this._sessions.unshift(this._createSession([])); - this._persistSessions(); - } else if (!this._sessions.length) { - this._sessions = [this._createSession([])]; - } - this._sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); - const current = this._sessions[0]; - this._currentSessionId = current.id; - this._messages = Array.isArray(current.messages) ? current.messages : []; + _persistableMessages(messages) { + return (messages || []).map(({ confirmation, payload, ...rest }) => rest); } - _persistSessions() { + async _upsertSessionToServer(session) { + const payload = { + id: session.id, + title: session.title || '', + mode: session.mode || MODE.GENERAL, + vault: session.vault ?? null, + directory: session.directory || '', + documents: Array.isArray(session.documents) ? session.documents : [], + context: session.context || '', + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messages: this._persistableMessages(session.messages), + }; + return api(`/api/ai/bookslm/history/${encodeURIComponent(payload.id)}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + } + + async _deleteSessionFromServer(id) { + return api(`/api/ai/bookslm/history/${encodeURIComponent(id)}`, { method: 'DELETE' }); + } + + /** Debounced best-effort push of local changes to the server. */ + _syncHistoryToServer() { + if (this._syncTimer) clearTimeout(this._syncTimer); + this._syncTimer = setTimeout(() => { + this._syncTimer = null; + this._flushHistoryToServer(); + }, 600); + } + + async _flushHistoryToServer() { + if (this._syncInFlight) return; + this._syncInFlight = true; + try { + for (const id of [...this._deletedIds]) { + try { + await this._deleteSessionFromServer(id); + this._deletedIds.delete(id); + } catch { /* offline: retry next time */ } + } + for (const session of this._sessions) { + if (!this._dirtySessionIds.has(session.id)) continue; + try { + await this._upsertSessionToServer(session); + this._dirtySessionIds.delete(session.id); + } catch { /* offline: keep dirty, retry next time */ } + } + } finally { + this._syncInFlight = false; + this._notifyHistoryChanged(); + } + } + + _persistLocalCache() { try { localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions)); } catch { /* quota / private mode */ } } - /** Strip transient (large) fields before persisting a conversation. */ - _persistableMessages() { - return this._messages.map(({ confirmation, payload, ...rest }) => rest); + /** Local cache + schedule the server sync (#95). */ + _persistSessions() { + this._persistLocalCache(); + this._syncHistoryToServer(); + this._notifyHistoryChanged(); + } + + /** Broad(cast) notification consumed by the sidebar history tab (#96). */ + _notifyHistoryChanged() { + try { + window.dispatchEvent(new CustomEvent('bookslm:history-updated')); + } catch { /* ignore */ } + } + + async _loadHistory(preferredSessionId = null) { + // 1. Local cache first (fast, offline-safe). + this._sessions = this._readSessions(); + for (const migrated of this._migrateLegacyHistory()) this._positionSession(migrated); + + // 2. Merge the server list (authoritative metadata); sessions present + // locally but unknown to the server are marked dirty and re-uploaded. + try { + const remote = await this._fetchHistorySummaryFromServer(); + if (remote.length) { + const remoteIds = new Set(); + const remoteBy = new Map(); + for (const r of remote) { + remoteIds.add(r.id); + remoteBy.set(r.id, r); + } + for (const s of this._sessions) { + if (!remoteIds.has(s.id)) this._dirtySessionIds.add(s.id); + } + for (const r of remote) { + if (remoteBy.has(r.id) && this._sessions.some((s) => s.id === r.id)) continue; + this._positionSession(this._normalizeSession({ ...r, messages: null })); + } + this._persistLocalCache(); + } + } catch { /* offline: keep the localStorage cache */ } + + if (this._pendingNewSession) { + // A "new conversation" was requested before the panel existed + // (command palette): start a fresh session on top of the existing ones. + this._pendingNewSession = false; + const created = this._createSession([]); + this._positionSession(created); + this._dirtySessionIds.add(created.id); + this._persistSessions(); + } else if (!this._sessions.length) { + const created = this._createSession([]); + this._positionSession(created); + this._dirtySessionIds.add(created.id); + this._persistSessions(); + } + + // 3. Pick the session to display: the requested one, else the latest of + // the current context, else any conversation. + let current = preferredSessionId + ? this._sessions.find((s) => s.id === preferredSessionId) || null + : this._sessions.find((s) => s.context === this._contextKey()) || this._sessions[0] || null; + if (!current) { + current = this._createSession([]); + this._positionSession(current); + } else if (!Array.isArray(current.messages)) { + const full = await this._fetchSessionFromServer(current.id); + current.messages = full && Array.isArray(full.messages) ? full.messages : []; + this._persistLocalCache(); + } + this._currentSessionId = current.id; + this._messages = current.messages || []; + this._notifyHistoryChanged(); } _saveHistory() { - const persisted = this._persistableMessages(); + const persisted = this._persistableMessages(this._messages); const session = this._currentSession(); if (session) { session.messages = persisted; session.updatedAt = Date.now(); if (!session.title) session.title = this._deriveTitle(this._messages); + this._dirtySessionIds.add(session.id); + this._positionSession(session); } else { const created = this._createSession(persisted); created.title = this._deriveTitle(this._messages); - this._sessions.unshift(created); + this._positionSession(created); this._currentSessionId = created.id; + this._dirtySessionIds.add(created.id); } this._persistSessions(); } @@ -3101,31 +3496,51 @@ class BooksLM { return line.length > 60 ? line.slice(0, 57) + '…' : line; } - _loadSession(id) { - const session = this._sessions.find((s) => s.id === id); + /** Select a session, hydrating it from the server when only a summary. */ + async _selectSessionById(id) { + let session = this._sessions.find((s) => s.id === id) || null; + if (!session || !Array.isArray(session.messages)) { + const fetched = await this._fetchSessionFromServer(id); + if (fetched && Array.isArray(fetched.messages)) { + session = this._normalizeSession(fetched); + this._positionSession(session); + } else if (session) { + session.messages = session.messages || []; + } + } if (!session) return; - this._currentSessionId = id; + this._currentSessionId = session.id; this._messages = Array.isArray(session.messages) ? session.messages : []; this._closeHistoryMenu(); if (this._panel) { + this._updateHeader(); this._renderMessages({ anchor: true }); this._showSuggestions(); this._renderHistoryMenu(); } + this._persistLocalCache(); + } + + async _loadSession(id) { + await this._selectSessionById(id); } _deleteSession(id) { this._sessions = this._sessions.filter((s) => s.id !== id); + this._dirtySessionIds.delete(id); + this._deletedIds.add(id); + let session; if (!this._sessions.length) { - const session = this._createSession([]); + session = this._createSession([]); this._sessions.push(session); - this._currentSessionId = session.id; - this._messages = session.messages; + this._dirtySessionIds.add(session.id); } else if (id === this._currentSessionId) { - const next = this._sessions[0]; - this._currentSessionId = next.id; - this._messages = next.messages || []; + session = this._sessions[0]; + } else { + session = this._currentSession() || this._sessions[0]; } + this._currentSessionId = session.id; + this._messages = session.messages || []; this._persistSessions(); if (this._panel) { this._renderMessages(); @@ -3174,11 +3589,13 @@ class BooksLM { const title = document.createElement('span'); title.className = 'bookslm-history-title'; title.textContent = session.title || t('bookslm.untitled'); - const date = document.createElement('span'); - date.className = 'bookslm-history-date'; - date.textContent = this._formatDate(session.updatedAt); + const meta = document.createElement('span'); + meta.className = 'bookslm-history-meta'; + const label = this._historyContextLabel(session); + const date = this._formatDate(session.updatedAt); + meta.textContent = label ? `${label} · ${date}` : date; open.appendChild(title); - open.appendChild(date); + open.appendChild(meta); open.addEventListener('click', () => this._loadSession(session.id)); const del = document.createElement('button'); @@ -3208,6 +3625,19 @@ class BooksLM { return ''; } } + + /** Short context reachable from a session, used by the history menu. */ + _historyContextLabel(session) { + if (!session) return ''; + const mode = session.mode || MODE.GENERAL; + if (mode === MODE.DIRECTORY) { + if (session.directory) return `📁 ${session.directory}`; + if (session.vault) return `📁 ${session.vault}`; + return t('bookslm.mode_directory'); + } + if (mode === MODE.DOCUMENTS) return t('bookslm.mode_documents'); + return t('bookslm.mode_general'); + } } const booksLM = new BooksLM(); diff --git a/frontend/js/config.js b/frontend/js/config.js index ea8ba0f..a47b92f 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -147,6 +147,73 @@ function initRecentTab() { _recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000); } +// --------------------------------------------------------------------------- +// AI history sidebar tab (#96) +// --------------------------------------------------------------------------- + +function _formatAIDate(ts) { + if (!ts) return ""; + try { + return new Date(ts).toLocaleString(); + } catch { + return ""; + } +} + +export async function loadAISessionList() { + const listEl = document.getElementById("ai-history-list"); + const emptyEl = document.getElementById("ai-history-empty"); + if (!listEl) return; + let sessions = []; + try { + const data = await api("/api/ai/bookslm/history"); + sessions = data.sessions || []; + } catch (err) { + console.error("Failed to load AI history:", err); + } + renderAIHistoryList(sessions); +} + +function renderAIHistoryList(sessions) { + const listEl = document.getElementById("ai-history-list"); + const emptyEl = document.getElementById("ai-history-empty"); + if (!listEl) return; + listEl.innerHTML = ""; + + if (!sessions || sessions.length === 0) { + if (emptyEl) { + emptyEl.classList.remove("hidden"); + safeCreateIcons(); + } + return; + } + if (emptyEl) emptyEl.classList.add("hidden"); + + sessions.forEach((s) => { + const item = el("div", { class: "recent-item", "data-session-id": s.id }); + const header = el("div", { class: "recent-item-header" }); + const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(_formatAIDate(s.updatedAt))]); + header.appendChild(timeSpan); + header.appendChild(el("span", { class: "recent-vault-badge" }, [document.createTextNode(t("bookslm.mode_" + (s.mode || "general"))) ])); + item.appendChild(header); + + const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(s.title || t("bookslm.untitled"))]); + item.appendChild(titleEl); + + if (s.preview) { + item.appendChild(el("div", { class: "recent-item-preview" }, [document.createTextNode(s.preview)])); + } + + item.addEventListener("click", () => { + closeMobileSidebar(); + import("./bookslm.js").then((m) => m.default.openWithSession(s.id)).catch((e) => console.error(e)); + }); + + listEl.appendChild(item); + }); + safeCreateIcons(); +} + // --------------------------------------------------------------------------- // Sidebar tabs // --------------------------------------------------------------------------- @@ -154,6 +221,10 @@ function initSidebarTabs() { document.querySelectorAll(".sidebar-tab").forEach((tab) => { tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab)); }); + // #96 — Refresh the sidebar AI history list whenever a conversation changes. + window.addEventListener("bookslm:history-updated", () => { + if (state.activeSidebarTab === "ai") loadAISessionList(); + }); } function switchSidebarTab(tab) { @@ -169,7 +240,7 @@ function switchSidebarTab(tab) { }); const filterInput = document.getElementById("sidebar-filter-input"); if (filterInput) { - const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" }; + const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "", ai: "" }; filterInput.placeholder = placeholders[tab] || ""; } const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; @@ -183,6 +254,10 @@ function switchSidebarTab(tab) { const vaultFilter = document.getElementById("recent-vault-filter"); loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } + // #96 — Auto-load the AI conversation history when entering its tab. + if (tab === "ai") { + loadAISessionList(); + } } function initHelpModal() { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 76082e4..fc708b0 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1829,6 +1829,28 @@ "bookslm.untitled": "Untitled conversation", "bookslm.no_vault": "No active vault to open this link", "bookslm.copied_name": "Name copied: {name}", + "bookslm.add_options": "Add options", + "bookslm.ext_files": "Upload files", + "bookslm.ext_files_hint": "Add files to the conversation context", + "bookslm.ext_image_hint": "Attach an image for vision-capable models", + "bookslm.ext_contexts": "Add contexts", + "bookslm.ext_contexts_hint": "Attach files or directories with the @ command", + "bookslm.ext_skills": "Add skills", + "bookslm.ext_skills_hint": "Select an active skill", + "bookslm.ext_deep_research": "Deep Research", + "bookslm.ext_deep_research_hint": "Multi-step in-depth research", + "bookslm.ext_web": "Internet search", + "bookslm.ext_web_hint": "Coming soon", + "bookslm.ext_canva": "Canva", + "bookslm.ext_canva_hint": "Coming soon", + "bookslm.coming_soon": "Feature coming soon", + "bookslm.soon": "Soon", + "bookslm.ext_more_coming": "More options coming…", + "bookslm.deep_research_prompt": "Perform an in-depth analysis of the request by breaking it into steps, searching for relevant information in the vault, cross-referencing them, and producing a structured summary with sources.", + "bookslm.deep_research_started": "Deep research started — enable Agent mode to use the tools.", + "bookslm.mode_general": "General", + "bookslm.mode_directory": "Directory", + "bookslm.mode_documents": "Documents", "palette.bookslm_open": "BooksLM: Open for current directory", "palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)", "palette.bookslm_new": "BooksLM: New conversation", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index b16eca0..68f5de8 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1829,6 +1829,28 @@ "bookslm.untitled": "Conversation sans titre", "bookslm.no_vault": "Aucun vault actif pour ouvrir ce lien", "bookslm.copied_name": "Nom copié : {name}", + "bookslm.add_options": "Options d'ajout", + "bookslm.ext_files": "Téléverser des fichiers", + "bookslm.ext_files_hint": "Ajouter des fichiers au contexte de la conversation", + "bookslm.ext_image_hint": "Joindre une image pour les modèles à vision", + "bookslm.ext_contexts": "Ajouter des contextes", + "bookslm.ext_contexts_hint": "Joindre des fichiers ou répertoires via la commande @", + "bookslm.ext_skills": "Ajouter des skills", + "bookslm.ext_skills_hint": "Sélectionner un skill actif", + "bookslm.ext_deep_research": "Deep Research", + "bookslm.ext_deep_research_hint": "Recherche approfondie à étapes multiples", + "bookslm.ext_web": "Recherche sur Internet", + "bookslm.ext_web_hint": "Bientôt disponible", + "bookslm.ext_canva": "Canva", + "bookslm.ext_canva_hint": "Bientôt disponible", + "bookslm.coming_soon": "Fonctionnalité à venir", + "bookslm.soon": "Bientôt", + "bookslm.ext_more_coming": "D'autres options à venir…", + "bookslm.deep_research_prompt": "Réalise une analyse approfondie de la demande en décomposant les étapes, en cherchant les informations pertinentes dans le vault, en les croisant, et en produisant une synthèse structurée avec sources.", + "bookslm.deep_research_started": "Deep Research lancé — activez le mode Agent pour utiliser les outils.", + "bookslm.mode_general": "Général", + "bookslm.mode_directory": "Répertoire", + "bookslm.mode_documents": "Documents", "palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant", "palette.bookslm_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)", "palette.bookslm_new": "BooksLM: Nouvelle conversation", diff --git a/frontend/style.css b/frontend/style.css index 71b8cc5..a92cb5d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9526,7 +9526,9 @@ body.popup-mode .content-area { .bookslm-input-area textarea:focus { border-color: var(--accent); outline: none; } .bookslm-input-area button { padding: 8px 16px; border-radius: 8px; border: none; background: var(--accent); color: #fff; cursor: pointer; font-size: 14px; } -.bookslm-input-area button.bookslm-btn-send { padding: 8px 12px; font-size: 16px; line-height: 1; } +.bookslm-input-area button.bookslm-btn-send { width: 40px; height: 40px; padding: 0; + border-radius: 50%; font-size: 16px; line-height: 1; display: flex; align-items: center; + justify-content: center; flex-shrink: 0; } .bookslm-input-area button:hover { opacity: 0.9; } .bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; } .bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary); @@ -9650,6 +9652,8 @@ details[open] > summary .bookslm-chevron::before { content: '▼'; } color: var(--text-primary); border-radius: 8px; } .bookslm-history-title { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bookslm-history-date { font-size: 10px; color: var(--text-secondary); } +.bookslm-history-meta { font-size: 10px; color: var(--text-secondary); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bookslm-history-delete { background: none; border: none; cursor: pointer; color: var(--text-secondary); padding: 0 8px; border-radius: 6px; display: flex; align-items: center; } .bookslm-history-delete:hover { color: #f87171; background: rgba(248,113,113,0.12); } @@ -9692,11 +9696,29 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; } white-space: nowrap; } .bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; } /* Image attach button. */ -.bookslm-btn-attach { display: flex; align-items: center; justify-content: center; flex-shrink: 0; +.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; - border-radius: 8px; padding: 7px; } -.bookslm-btn-attach:hover { color: var(--accent); border-color: var(--accent); } + border-radius: 50%; width: 32px; height: 32px; padding: 0; } +.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); } .bookslm-file-input { display: none; } +.bookslm-files-input { display: none; } +/* Extensible "+" panel (#97). */ +.bookslm-ext-menu { position: absolute; left: 12px; right: 12px; bottom: 72px; z-index: 45; + max-height: 55vh; overflow-y: auto; background: var(--bg-primary); border: 1px solid var(--border); + border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); padding: 6px; } +.bookslm-ext-menu.hidden { display: none; } +.bookslm-ext-item { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; + gap: 8px; width: 100%; text-align: left; background: none; border: none; cursor: pointer; + padding: 8px 9px; border-radius: 8px; color: var(--text-primary); font-size: 13px; } +.bookslm-ext-item:hover { background: var(--bg-hover); } +.bookslm-ext-item .bookslm-ext-hint { grid-column: 2 / 4; font-size: 11px; color: var(--text-secondary); + margin-top: -4px; } +.bookslm-ext-item.disabled { opacity: 0.55; cursor: not-allowed; } +.bookslm-ext-item.disabled:hover { background: none; } +.bookslm-ext-soon { font-size: 9px; text-transform: uppercase; letter-spacing: 0.4px; + color: var(--accent); border: 1px solid var(--accent); border-radius: 10px; padding: 1px 6px; } +.bookslm-ext-footer { padding: 7px 9px 4px; font-size: 10px; color: var(--text-secondary); + text-align: center; border-top: 1px dashed var(--border); margin-top: 4px; } /* Create-skill modal. */ .bookslm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 2000; display: flex; align-items: center; justify-content: center; padding: 16px; } diff --git a/package.json b/package.json index 2762299..e93776a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.3.4", + "version": "2.4.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/scripts/bump_version.py b/scripts/bump_version.py index eef9ab2..55eceac 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -474,6 +474,7 @@ def resolve_bump(args: argparse.Namespace, root: Path) -> str | None: def main(argv: list[str] | None = None) -> int: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") args = build_parser().parse_args(argv) root = REPO_ROOT diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index f7ac2c3..250d4fa 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -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"); }); diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py index 3d4b393..8c0a803 100644 --- a/tests/test_bookslm.py +++ b/tests/test_bookslm.py @@ -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"] == []