Compare commits

...
10 Commits
Author SHA1 Message Date
bruno 0d4f43a8bf test(ai): collecter toutes les requetes pour eviter la course avec l'hydratation d'historique
CI / lint (push) Successful in 1m31s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 2m42s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 11m10s
2026-09-16 20:22:15 -04:00
bruno 4231f2e929 feat(sidebar+assistant): filtre Recents/Sauvegardes (#99), pastille Deep Research (#100) et icone du bouton + (BUG-049)
CI / lint (push) Failing after 1m20s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 59s
2026-09-16 20:16:41 -04:00
bruno 8f26a418a9 test(ai): rendre le test du menu mention non flaky (nettoyage d'etat + attente du fetch)
CI / lint (push) Successful in 1m29s
CI / security (push) Successful in 1m3s
CI / test (push) Successful in 2m55s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 10m44s
2026-09-16 19:30:15 -04:00
bruno f50a9f5bf3 docs(roadmap): clore la livraison #98 + BUG-048 avec la confirmation CI (v2.5.0, run #1511)
CI / lint (push) Failing after 1m19s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 58s
2026-09-16 16:29:36 -04:00
bruno 8e2b6203a1 feat: filtre de recherche dans la sidebar Historique IA (#98) + menus '@' et '/' depuis le menu '+' (BUG-048)
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m19s
CI / build (push) Successful in 54s
CI / e2e (push) Successful in 10m44s
2026-09-16 16:11:54 -04:00
bruno c9e3240ae2 feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m9s
CI / build (push) Successful in 1m3s
CI / e2e (push) Successful in 11m12s
2026-09-16 14:56:45 -04:00
bruno 0841778b99 docs(agents): versionnage automatique documente dans AGENTS.md + menage des fichiers temporaires
CI / lint (push) Successful in 1m26s
CI / security (push) Successful in 57s
CI / test (push) Successful in 2m16s
CI / build (push) Successful in 53s
CI / e2e (push) Successful in 10m40s
AGENTS.md : la checklist de fin de tache rappelle que VERSION est la source unique de verite, incrementee a chaque commit par le hook, avec resynchronisation des derives et publication du tag au push ; ligne ajoutee a la cartographie documentaire. Suppression des captures de diagnostic (diag-*.png, ogdiag.png, state.png) et du script jetable tmp-verify-inline.cjs.
2026-09-16 12:12:06 -04:00
bruno 788d84a2bf fix(editeur): #93 garde-fous de session d'edition inline (onglets, ecriture externe) + hooks de versionnage documentes
CI / lint (push) Successful in 1m27s
CI / security (push) Successful in 57s
CI / test (push) Successful in 2m52s
CI / build (push) Successful in 53s
CI / e2e (push) Successful in 10m45s
Une session d'edition inline (Editer/Forge) qui possede la zone de contenu etait detruite par deux chemins qui vident cette zone : activation d'onglet (TabManager.activate, PaneTabManager.activate) -> detachInlineEditor() avant le placeholder ; evenement SSE index_updated -> reloadExternalWrite() recharge le tampon de l'editeur au lieu de re-rendre la vue lecture. Nouveau helper queryEditor() ; closeEditor() remet le conteneur dans la modale avant de restaurer en-tete/pied/marque. docs/CONTRIBUTING.md documente scripts/install-hooks.sh.
2026-09-16 11:43:41 -04:00
bruno 168261964e docs(version): preciser le SHA reecrit par l'amend du hook post-commit
CI / lint (push) Successful in 1m27s
CI / security (push) Successful in 57s
CI / test (push) Successful in 3m27s
CI / build (push) Successful in 53s
CI / e2e (push) Successful in 10m59s
2026-09-16 11:20:29 -04:00
bruno 2f129c3f23 chore(scripts): outil de controle du CI Gitea (runs et jobs par head_sha)
scripts/check_ci.py : interroge l'API Gitea (jeton via git credential fill, User-Agent navigateur) et rapporte l'etat des jobs du run correspondant au HEAD local. Bump automatique de version = demonstration du hook prepare-commit-msg.
2026-09-16 11:20:13 -04:00
41 changed files with 2456 additions and 217 deletions
+4
View File
@@ -46,6 +46,8 @@ jobs:
node excalidraw-viewer.test.mjs
node plugins.test.mjs
node ai.test.mjs
node ai-sidebar.test.mjs
node sidebar-filters.test.mjs
node sw.test.mjs
node collab.test.mjs
node mobile-editor.test.mjs
@@ -60,6 +62,8 @@ jobs:
node excalidraw-viewer.test.mjs
node plugins.test.mjs
node ai.test.mjs
node ai-sidebar.test.mjs
node sidebar-filters.test.mjs
node sw.test.mjs
node collab.test.mjs
node mobile-editor.test.mjs
+9
View File
@@ -23,6 +23,14 @@ Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI v
(statut + index), fiche `docs/features/` **ou** `docs/archive/`, `docs/ISSUES_TODOLIST.md`,
guide utilisateur i18n FR/EN + README si impact utilisateur.
- **Commit** conventionnel référençant l'ID, puis **push**.
- Version : le fichier VERSION (racine du dépôt) est la **source unique de
vérité (MAJEUR.MINEUR.CORRECTIF), incrémenté automatiquement à chaque commit** par le hook
.githooks/prepare-commit-msg — feat → mineur, !: / BREAKING CHANGE → majeur, sinon
correctif. Le même commit resynchronise package.json, le desktop Tauri, README.md/
README.fr.md, docs/ROADMAP.md et publie la section [Unreleased] du CHANGELOG.md en
[X.Y.Z] — date ; le tag vX.Y.Z est créé au commit et publié au push (push.followTags).
Hooks à installer une fois par clone : scripts/install-hooks.sh. Garde-fou :
tests/test_version.py (détail : docs/DELIVERY_WORKFLOW.md §7).
- Vérifier le **CI Gitea vert** (jobs `lint`, `test`, `security`, `build`, `e2e`).
## Cartographie documentaire
@@ -30,6 +38,7 @@ Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI v
| Sujet | Fichier |
|---|---|
| Méthode de livraison / DoD | `docs/DELIVERY_WORKFLOW.md` |
| Version livrée (source unique) | VERSION + scripts/bump_version.py |
| Travail à venir + index | `docs/ROADMAP.md` |
| Historique des versions | `CHANGELOG.md` |
| Conception par feature | `docs/features/<slug>.md` |
+138 -1
View File
@@ -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.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.6.1**.
---
@@ -14,6 +14,143 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.6.1] — 2026-09-16
---
## [2.6.0] — 2026-09-16
### Ajouté
- **Barre de filtrage de la sidebar sur « Récents » et « Sauvegardes » (#99)** : la barre
de recherche de la sidebar agit désormais sur les onglets **Récents**
(`filterRecentFiles`, filtrage titre/chemin/vault/aperçu/tags) et **Sauvegardes**
(`filterSavedSearches`, cumulable avec les pills Tous/Recherches/Répertoires) —
insensible à la casse et aux accents, avec message d'absence de résultat et
placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`). Le routage de
`initSidebarFilter` est unifié (`routeFilter`/`routeClear`) pour couvrir les cinq
onglets. Fiche : [docs/features/sidebar-filters.md](./docs/features/sidebar-filters.md).
- **Assistant IA — Deep Research en pastille (#100)** : « Deep Research » ajoute
désormais une **pastille** (comme les skills) au lieu d'écrire la directive dans la
zone de saisie ; le mode Agent est activé et la directive est injectée au moment de
l'envoi, sans polluer le message affiché.
### Corrigé
- **BUG-049 — icône du bouton « + » de l'assistant invisible** : la règle générique
`.bookslm-input-area button` écrasait `.bookslm-btn-plus` (`padding: 8px 16px` sur une
largeur de 32 px ⇒ largeur de contenu nulle ⇒ SVG à 0 px). Sélecteur porté à
`.bookslm-input-area button.bookslm-btn-plus`, l'icône `plus` est de nouveau visible
(vérifié en navigateur : SVG 0 px → 18 px). Tests : `tests/frontend/ai.test.mjs` (+1),
`tests/frontend/sidebar-filters.test.mjs` (nouveau, 8 tests).
---
## [2.5.2] — 2026-09-16
---
## [2.5.1] — 2026-09-16
---
## [2.5.0] — 2026-09-16
### Ajouté
- **Assistant IA — filtre de recherche dans la sidebar « Historique IA » (#98)** : la barre
de filtrage de la sidebar agit désormais sur l'onglet Historique IA. `filterAIHistory()`
(`frontend/js/config.js`) filtre les sessions par titre, aperçu, répertoire, contexte ou
libellé de mode — insensible à la casse et aux accents (`_aiNorm`) — avec cache sessions
(`_aiSessionsCache`), message « aucune conversation ne correspond à la recherche »
(`bookslm.history_no_match`) et placeholder dédié `sidebar.filter_ai`. Le routage
saisie / touche casse / bouton « × » vers ce filtre est géré dans `initSidebarFilter`
(`frontend/js/sidebar.js`) quand l'onglet IA est actif.
### Corrigé
- **BUG-048 — menu d'ajout « + » : « Contextes » et « Skills » ouvrent enfin leurs menus
« @ » / « / »** : le clic sur une entrée du panneau `.bookslm-ext-menu` remontait au
gestionnaire du panneau qui annulait le rendu asynchrone du menu (jamais affiché) ;
`e.stopPropagation()` sur les entrées corrige le comportement. Le bouton « + » porte
l'icône Lucide `plus`. Tests : `tests/frontend/ai.test.mjs` (+3), nouvelle suite
`tests/frontend/ai-sidebar.test.mjs` (6 tests).
---
## [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
- **AGENTS.md — versionnage automatique** : la méthode obligatoire de fin de tâche rappelle
désormais que `VERSION` (racine) est la source unique de vérité (`MAJEUR.MINEUR.CORRECTIF`),
incrémentée à chaque commit par le hook `.githooks/prepare-commit-msg`, que les dérivés
(`package.json`, desktop Tauri, READMEs, `docs/ROADMAP.md`, `CHANGELOG.md`) sont
resynchronisés dans le même commit, et que le tag `vX.Y.Z` est publié au push. Ligne
ajoutée à la cartographie documentaire. Ménage : suppression des captures de diagnostic
(`diag-*.png`, `ogdiag.png`, `state.png`) et du script jetable `tmp-verify-inline.cjs`
restés à la racine du dépôt.
---
## [2.3.3] — 2026-09-16
### Corrigé
- **#93 (complément) — garde-fous de session d'édition inline** : une session d'édition
(« Editer » ou Forge) qui possède la zone de contenu était détruite par deux chemins
qui vident cette zone sans la libérer d'abord — l'**activation d'un onglet**
(`TabManager.activate`, `PaneTabManager.activate`) et l'événement SSE **`index_updated`**
sur le fichier affiché (un fichier modifié hors de l'app relançait `openFile()` et écrasait
le DOM de l'éditeur). L'activation d'onglet appelle désormais `detachInlineEditor()` avant
le placeholder de chargement, et l'écriture externe passe par `reloadExternalWrite()` (le
**tampon de l'éditeur** est rechargé depuis le disque au lieu d'un re-rendu de la vue
lecture). Nouveau helper `queryEditor()` (`editor-inline.js`) : l'en-tête, le pied et la
marque voyagent avec le conteneur en mode inline, un `modal.querySelector()` les manquait ;
`closeEditor()` remet le conteneur dans la modale **avant** de restaurer en-tête/pied/marque.
Fiche : [docs/features/editeur-inline.md](./docs/features/editeur-inline.md).
Tests : `tests/frontend/editor-inline.test.mjs` (25).
- **Documentation — hooks git obligatoires à l'installation** : `docs/CONTRIBUTING.md`
documente l'appel à `scripts/install-hooks.sh` (versionnage automatique) dans les étapes
de mise en place d'un clone.
---
## [2.3.2] — 2026-09-16
---
## [2.3.1] — 2026-09-16
---
## [2.3.0] — 2026-09-16
### Ajouté
+3 -3
View File
@@ -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.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.6.1-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.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.6.1).
---
*Projet : ObsiGate | Version : 2.3.0 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.6.1 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -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.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.6.1-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.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.6.1).
---
*Project: ObsiGate | Version: 2.3.0 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.6.1 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.3.0
2.6.1
+175
View File
@@ -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
+84
View File
@@ -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-<ts36>-<rand>)")
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)}
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.3.0"
version = "2.6.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.3.0"
version = "2.6.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.3.0",
"version": "2.6.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+9
View File
@@ -30,8 +30,17 @@ source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
pip install -r backend/requirements.txt
# Activer les hooks git versionnés (.githooks) : incrément automatique de la
# version livrée (VERSION) à chaque commit + publication du tag vX.Y.Z au push.
scripts/install-hooks.sh
```
> **Hooks obligatoires** : `scripts/install-hooks.sh` pose `core.hooksPath=.githooks` et
> `push.followTags=true`. Sans eux, la version (`VERSION`) ne suit plus les livraisons et le
> CI reste sur un numéro obsolète. Détail : [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) §3.
> Contournement ponctuel d'un commit : `SKIP_VERSION_BUMP=1 git commit …`.
### 2. Configurer les vaults de test
Créez un dossier `test_vault/` (ignoré par `.gitignore`) avec quelques fichiers `.md` :
+3
View File
@@ -134,6 +134,9 @@ npx playwright test
diverge de `VERSION` (numéro codé en dur, CHANGELOG sans section, README non resynchronisé).
- Bump manuel si besoin : `scripts/bump_version.py --dry-run|--minor|--set X.Y.Z|--push`.
Commit sans incrément (cas exceptionnel) : `SKIP_VERSION_BUMP=1 git commit …`.
Le rattachement des fichiers au commit se fait par un `--amend` immédiat (commit non encore
poussé) : le SHA affiché par `git commit` est donc remplacé par celui de l'amend — `git log`,
`HEAD` et le tag `vX.Y.Z` restent, eux, alignés sur la version livrée.
- **Ne jamais** réécrire une version déjà publiée dans le CHANGELOG.
---
+5
View File
@@ -210,6 +210,11 @@ git push origin main # → branche + tag publiés
Contournement ponctuel (commit sans incrément) : `SKIP_VERSION_BUMP=1 git commit …`.
> **SHA affiché vs SHA réel** : l'incrément et les fichiers dérivés sont rattachés au
> commit par un `--amend` immédiat (le commit n'est pas encore poussé), donc le SHA
> affiché par `git commit` est remplacé par celui de l'amend — `git log`, `HEAD` et le
> tag `vX.Y.Z` sont, eux, alignés sur ce dernier. `git push` envoie bien la version finale.
### Bump manuel / outillage
```bash
+6 -1
View File
@@ -200,7 +200,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-14 | BUG-043, #82 | Correction | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-provider-picker.md` | La liste des fournisseurs de la barre latérale de l'assistant suit désormais la configuration du projet : nouveau `refreshAIPickers()` (`ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (host conservé dans la barre de l'assistant, montage par `replaceChildren`), appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; slot préservé même sans fournisseur configuré (le premier fournisseur ajouté s'y monte) ; sélection persistée d'un fournisseur non configuré purgée de `obsigate_ai_picker` (retour au défaut, modèle effacé). Vérifié : tests frontend 66/66 (IA) + 9 suites JSDOM vertes, validate-imports 36 modules, pytest 963 passed / 6 skipped, ruff OK, et vérification en navigateur (Playwright) sur l'instance de test : ajout de `nvidia` → fournisseur visible sans rechargement, retrait → disparition + sélection réinitialisée (`{"provider":null,"model":null}`), un seul montage du picker dans son host. CI Gitea verte (lint, test, security, build, e2e). |
| 2026-09-15 | BUG-044 | Correction | `backend/provider_capabilities.py` (nouveau), `backend/model_capabilities.py`, `backend/main.py`, `backend/ai_routes.py`, `tests/test_provider_capabilities.py` (nouveau), `tests/test_model_capabilities.py`, `tests/test_ai_models.py`, `docs/features/ai-provider-picker.md`, `CHANGELOG.md` | BUG-044 : les capacités des modèles IA sont désormais lues chez le fournisseur quand il les publie (Mistral `capabilities`, OpenRouter `architecture`, détection par forme du payload), mises en cache par `GET /api/config/ai-models` (aucune requête supplémentaire) et prioritaires sur la table statique qui ne comble plus que les drapeaux non déclarés (repli hors ligne / sans clé). Table statique corrigée : familles vision Mistral (`ministral`, `magistral`, `mistral-small`, `mistral-medium`, `mistral-vibe-cli`, `labs-leanstral`), `mistral-ocr` = vision sans chat, défaut fournisseur Mistral sans `embeddings` (mistral-large / codestral n'étaient plus des « embedders »). Vérifié : diagnostic live avant/après sur l'API Mistral (28 modèles vision déclarés, **0** détectés avant → **28** après, 0 écart dans les deux sens), pytest 1007 passed / 6 skipped, ruff 0 (backend), mypy 0 (68 fichiers), tests frontend (unit 9/9, IA 66/66, validate-imports 37 modules), et vérification sur l'instance de test reconstruite (`obsigate-test`, http://localhost:2020) via les deux endpoints du panneau : `mistral-small/medium-latest`, `ministral-8b-latest`, `magistral-small-latest` → `chat,vision` ; `mistral-ocr-latest` → `vision` seul ; `mistral-large-latest`/`codestral-latest` → `chat` ; `mistral-embed` → `embeddings` seul ; `deepseek-chat` (fournisseur muet) inchangé. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-045, BUG-046 | Correction | `frontend/style.css`, `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py`, `frontend/sw.js`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md` | BUG-045 : fin de la double barre de défilement de l'éditeur « Editer » — `#editor-body` en flex column `overflow:hidden`, `.cm-editor` `flex:1 1 auto`, seul le scroller CodeMirror défile ; suppression de l'override global `.cm-scroller` (min-height/overflow-y !important). BUG-046 : « Échec de l'action : [object Object] » à l'application d'un ajout de texte au document courant — (1) `_responseError()` aplatit le `detail` 422 (tableau d'objets FastAPI) en message lisible, (2) la continuation de confirmation hérite du payload d'origine (le second « Appliquer » ne POSTe plus sans `message`), (3) le prompt documents/dossier nomme le vault et enjoint les outils d'écriture d'utiliser ce nom exact (le modèle inventait `"vault":"test"` → échec silencieux). `SW_VERSION` v20. Vérifié : pytest 1038 passed / 6 skipped, ruff 0 (backend), tests frontend IA 84/84, editor-inline 25/25, validate-imports 38 modules ; reproduction et validation Playwright sur l'instance de test (un seul conteneur scrollable après correctif). | 🟢 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 | #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) |
| 2026-09-16 | BUG-048, #98 | Correction + feature | `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/ai-sidebar.test.mjs` (nouveau), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-048** : les entrées « Contextes » et « Skills » du menu « + » ouvraient bien leur menu (`@` / `/`), mais le clic remontait au gestionnaire du panneau qui annulait le rendu asynchrone → menu jamais affiché ; correction par `e.stopPropagation()` sur les entrées du panneau `.bookslm-ext-menu`. **#98** : la barre de filtrage de la sidebar agit désormais sur l'onglet « Historique IA » — `filterAIHistory()` (config.js) filtre par titre, aperçu, répertoire, contexte ou libellé de mode, insensible casse/accents (`_aiNorm`), cache sessions `_aiSessionsCache`, message « aucune correspondance » (`bookslm.history_no_match`) dans la liste et placeholder dédié (`sidebar.filter_ai`) ; `initSidebarFilter` (sidebar.js) route saisie/touche casse/bouton « × » vers `filterAIHistory` quand l'onglet IA est actif ; chaque entrée du panneau « + » porte l'icône Lucide `plus`. Vérifié : tests frontend IA 87/87 (+3), nouvelle suite `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, pytest / ruff / mypy inchangés (aucune modification backend). | 🟢 corrigé (en attente vérif utilisateur) — CI Gitea verte (lint, test, security, build, e2e) pour v2.5.0 (run #1511) |
| 2026-09-16 | BUG-049, #99, #100 | Correction + feature | `frontend/style.css`, `frontend/js/config.js`, `frontend/js/viewer.js`, `frontend/js/sidebar.js`, `frontend/js/bookslm.js`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/sidebar-filters.test.mjs` (nouveau), `docs/features/sidebar-filters.md` (nouvelle), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-049** : icône du bouton « + » de l'assistant invisible — la règle générique `.bookslm-input-area button` (spécificité supérieure) imposait `padding: 8px 16px` sur un bouton `width: 32px` ⇒ largeur de contenu nulle ⇒ SVG `width: 0px` ; sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (+ `:hover`), vérifié en navigateur (Playwright : SVG 0 px → 18 px). **#99** : la barre de filtrage de la sidebar agit désormais sur les vues **Récents** (`filterRecentFiles`, titre/chemin/vault/aperçu/tags) et **Sauvegardes** (`filterSavedSearches`, cumulable avec les pills type), insensible casse/accents (`_sidebarNorm`/`_savedNorm`), message d'absence de résultat (`sidebar.no_results`) et placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`) ; `initSidebarFilter` refactoré en `routeFilter`/`routeClear` couvrant les 5 onglets. **#100** : « Deep Research » ajoute une **pastille** `.bookslm-chip-deep-research` (au lieu d'injecter la directive dans le composeur), active le mode Agent et injecte la directive au moment de l'envoi. Vérifié : tests frontend IA 88/88 (+1), `sidebar-filters` 8/8 (nouveau), `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, vérification navigateur du bouton « + » ; backend inchangé (pytest / ruff / mypy valides). | 🟢 corrigé (en attente vérif utilisateur) |
---
+10 -61
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.3.0 | **Dernière mise à jour :** 2026-09-16
> **Version :** 2.6.1 | **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,13 @@
| 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) |
| 98 | Assistant IA — Filtre de recherche dans la sidebar « Historique IA » | 2.5.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
| 99 | Sidebar — Filtrage des vues « Récents » et « Sauvegardes » | 2.6.0 | [features/sidebar-filters.md](./features/sidebar-filters.md) |
| 100 | Assistant IA — Deep Research en pastille (au lieu du texte injecté) | 2.6.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
---
@@ -254,13 +204,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–100 | ~114 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** |
---
+177
View File
@@ -0,0 +1,177 @@
# #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-<ctx>` et
`bookslm-history-<ctx>` à 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.
## H. Complément — filtre de la sidebar et menu « + » (BUG-048, #98) — ✅ livré
En retour utilisateur sur la version 2.4.0 :
*(1) **Filtre fonctionnel sur l'onglet « Historique IA » (sidebar) — #98** — la barre de
filtrage globale de la sidebar agissait uniquement sur les onglets Fichiers/Tags. Elle
s'applique désormais aussi à l'historique IA :*
- Utile `filterAIHistory(query)` (`frontend/js/config.js`) : filtre le cache de sessions
`_aiSessionsCache` (peuplé par `loadAISessionList()`) sur **titre, aperçu, répertoire,
contexte ou libellé de mode** traduit, insensible à la casse et aux accents
(`_aiNorm` : `NFD` + suppression des diacritiques + `toLowerCase`). Une requête sans
résultat affiche `bookslm.history_no_match` (nouvelle clé i18n FR/EN) **dans la liste**,
en plus de l'état vide d'origine ; un champ vide restaure tout.
- Routage dans `initSidebarFilter` (`frontend/js/sidebar.js`) : la saisie (debounce 220 ms),
le bouton casse `Aa` et le bouton « × » dirigent vers `filterAIHistory` quand l'onglet IA
est actif (`state.activeSidebarTab`), au lieu de `filterTagCloud`. Le placeholder devient
`sidebar.filter_ai` (« Filtrer l'historique IA… » / « Filter AI history… ») — résolu dans
`switchSidebarTab` (config.js), qui re-charge aussi la liste à chaque entrée dans l'onglet
en ré-appliquant la requête courante.
*(2) **Menu « + » du panneau Assistant — BUG-048** — « ajouter des contextes » ouvrait un
menu « @ » jamais affiché (et idem pour « ajouter des skills » → « / ») :* le clic sur une
entrée du panneau `.bookslm-ext-menu` remontait au gestionnaire `click` du panneau
(`_hideMenus()` + `_menuSeq++`), qui annulait le rendu asynchrone du menu ouvert juste après.
`e.stopPropagation()` est ajouté sur chaque entrée de `_renderExtMenu()` (bookslm.js) :
les menus « @ » (contextes) et « / » (skills) restent affichés. Le bouton d'ouverture porte
bien l'icône Lucide `plus` (vérifié par test JSDOM).
**Points d'attention**
- Le filtrage reste **client-side** : la charge est triviale vu le cap de 200 sessions
(`MAX_SESSIONS`). Un futur filtrage serveur n'est justifié que si la rétention croît.
- `filterAIHistory` est exportée en plus de `loadAISessionList` : `validate-imports` couvre le
nouveau contrat d'import de `sidebar.js`.
### Tests du complément
- `tests/frontend/ai-sidebar.test.mjs` (nouveau, 6 tests) : rendu complet, filtre par titre
(casse), accents (« cafe » → « café au lait »), aperçu/répertoire, restauration complète,
message « aucune correspondance ».
- `tests/frontend/ai.test.mjs` (+3) : clic « Contextes » → menu « @ » visible listé, clic
« Skills » → menu « / » visible listé, icône `plus` présente sur le bouton « + ».
- Vérifs : frontend IA **87/87**, `ai-sidebar` **6/6**, `unit` 9/9, 9 suites JSDOM vertes,
`validate-imports` 38 modules ; backend inchangé (pytest / ruff / mypy valides).
## 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é.
## I. Complément — icône « + » et pastille Deep Research (BUG-049, #100) — ✅ livré
*(1) **BUG-049 — l'icône du bouton « + » n'était pas visible.*** Le SVG Lucide était
bien rendu, mais la règle générique `.bookslm-input-area button { padding: 8px 16px;
background: var(--accent); color:#fff }` l'emportait en **spécificité** sur
`.bookslm-btn-plus` (une classe seule). Le bouton conservait `width:32px` avec
`padding: 8px 16px` → **largeur de contenu = 0 px**, donc SVG à `width: 0px`
(invisible). Correctif CSS : sélecteur porté à
`.bookslm-input-area button.bookslm-btn-plus` (et `:hover`), qui reprend la main
(`padding:0`, fond transparent, couleur `--text-secondary`). Vérifié en navigateur
(Playwright) : `svgWidth` passe de `0px` à `18px`.
*(2) **#100 — Deep Research devient une pastille (comme les skills).*** Auparavant,
cliquer sur « Deep Research » injectait la directive de recherche dans la zone de
saisie. Désormais :
- `_startDeepResearch()` active le **mode Agent** (si nécessaire), positionne le drapeau
`_activeDeepResearch` et rend une **pastille** `.bookslm-chip-deep-research`
(`_renderAttachments()`), sans rien écrire dans le composeur.
- La pastille se retire via son « × » (comme les chips skills/fichiers) et remet le
drapeau à `false`.
- La directive (`bookslm.deep_research_prompt`) est injectée **au moment de l'envoi**
dans le `message` du payload (`_sendMessage()`), sans polluer le message affiché à
l'utilisateur.
- Message d'information mis à jour (`bookslm.deep_research_started`) : « Deep Research
activé — ajoutez votre question puis envoyez. »
### Tests du complément
- `tests/frontend/ai.test.mjs` (+1) : le clic sur « Deep Research » ajoute une pastille,
laisse le composeur vide, positionne le drapeau, et le retrait de la pastille remet
le drapeau à `false`.
- Vérification navigateur du bouton « + » (Playwright, instance de test).
+4
View File
@@ -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 ? »).
+2
View File
@@ -59,6 +59,8 @@ d'édition mobile `.me-ribbon`, ancré à la modale, conserve `pointer-events: a
| ✓ (Sauvegarder) | sauvegarde puis `closeEditor()` → retour à la lecture (contenu relu depuis le disque) |
| ✕ (Annuler) / **Échap** | `closeEditor()` → retour à la lecture (les modifications non sauvegardées sont abandonnées, comme avant) |
| Ouverture d'un autre fichier (`renderFile()`) | garde-fou : `detachInlineEditor()` libère la session (destroy CodeMirror/Yjs, retrait de l'iframe Forge) avant le rendu de la lecture |
| Activation d'un onglet (`TabManager.activate`, `PaneTabManager.activate`) | même garde-fou **avant** le placeholder de chargement : ces fonctions vident la zone de contenu, ce qui détruirait le DOM de l'éditeur |
| Événement SSE `index_updated` sur le fichier affiché | `reloadExternalWrite()` recharge le **tampon de l'éditeur** depuis le disque au lieu de re-rendre la vue lecture (sans quoi la modification externe détruisait la session) |
| `forge-close` (message de l'iframe) | route vers `closeEditor()` — plus de manipulation manuelle de la modale dans `sync.js` |
**Repli overlay** : si la cible n'est pas le document affiché (fichier venant d'être créé via la
+72
View File
@@ -0,0 +1,72 @@
# #99 — Barre de filtrage de la sidebar sur « Récents » et « Sauvegardes »
> **Statut :** ✅ livré · **Version :** 2.6.0 · **ID :** #99
> **Lié :** BUG-049 (icône du bouton « + » de l'assistant), #100 (pastille Deep Research).
## Contexte
La barre de recherche/filtrage de la sidebar (`#sidebar-filter-input`) agissait
historiquement sur les onglets **Fichiers** (arborescence) et **Tags**. L'onglet
**Historique IA** a reçu son propre filtrage en #98. Les onglets **Récents** et
**Sauvegardes** (« saved searches ») n'étaient pas couverts : taper dans la barre
n'avait aucun effet dans ces deux vues.
## A. Onglet « Récents » — ✅ livré
- Nouveau cache module `_recentQuery` et fonction exportée `filterRecentFiles(query)`
(`frontend/js/config.js`).
- `_applyRecentFilter(files)` filtre le cache `_recentFilesCache` sur **titre, chemin,
vault, aperçu et tags**, via `_sidebarNorm()` (normalisation `NFD` + suppression des
diacritiques + minuscules) — donc insensible à la casse **et** aux accents.
- `loadRecentFiles()` applique désormais le filtre courant après chaque chargement.
- Aucun résultat → un message `sidebar.no_results` est inséré dans `#recent-list`
(classe `.sidebar-filter-empty`, style existant).
- `switchSidebarTab("recent")` applique la requête courante et pose le placeholder
`sidebar.filter_recent`.
## B. Onglet « Sauvegardes » — ✅ livré
- Nouveau `_savedQuery` et fonction exportée `filterSavedSearches(query)`
(`frontend/js/viewer.js`).
- `_applySavedFilter()` combine désormais **le filtre de type** (pills Tous / Recherches
/ Répertoires) **et la requête texte** : un élément est visible si son `data-type`
correspond au pill **et** si son texte (requête, vault, chemins inclus/exclus) contient
la requête normalisée (`_savedNorm`).
- Aucun résultat avec une requête active → message `sidebar.no_results` ajouté à
`#saved-searches-list` (nettoyé à chaque ré-application pour ne pas s'accumuler).
- `switchSidebarTab("saved")` ré-applique la requête et pose le placeholder
`sidebar.filter_saved`.
## C. Routage unifié de la barre de filtrage — ✅ livré
`initSidebarFilter()` (`frontend/js/sidebar.js`) est refactorisé autour de deux
fonctions `routeFilter(q)` / `routeClear()` qui dirigent la saisie, la bascule
casse (`Aa`) et le bouton « × » vers le filtre de l'onglet actif :
| Onglet | Filtre |
|---|---|
| `vaults` | `performTreeSearch` / `restoreSidebarTree` |
| `recent` | `filterRecentFiles` |
| `saved` | `filterSavedSearches` |
| `ai` | `filterAIHistory` |
| `tags` | `filterTagCloud` |
## D. i18n — ✅ livré
- `sidebar.filter_recent` : « Filtrer les fichiers récents... » / "Filter recent files..."
- `sidebar.filter_saved` : « Filtrer les recherches sauvegardées... » / "Filter saved searches..."
- Le message d'absence de résultat réutilise `sidebar.no_results`.
## E. Tests — ✅ livré
- `tests/frontend/sidebar-filters.test.mjs` (nouveau, 8 tests) : rendu des listes,
filtrage par titre/casse, accents/tags, vault/chemin, restauration, message
d'absence de résultat, et combinaison pill + requête sur les sauvegardes.
- Ajouté à la liste explicite du job `lint` de `.gitea/workflows/ci.yml`.
## F. Points d'attention
- Le filtrage est **client-side** (les listes sont déjà chargées) : aucune requête
réseau supplémentaire n'est déclenchée par la frappe.
- Sur « Sauvegardes », le filtre de type et la requête sont **cumulatifs** ; vider la
barre (ou « × ») ne réinitialise pas le pill actif.
+31
View File
@@ -917,6 +917,20 @@
style="width: 18px; height: 18px"
></i>
</button>
<button
class="sidebar-tab"
id="sidebar-tab-ai"
role="tab"
aria-selected="false"
aria-controls="sidebar-panel-ai"
data-tab="ai"
title="Historique IA"
>
<i
data-lucide="messages-square"
style="width: 18px; height: 18px"
></i>
</button>
</div>
<!-- Vaults panel -->
@@ -1021,6 +1035,23 @@
<span>Aucune recherche sauvegardee</span>
</div>
</div>
<!-- AI history panel -->
<div
class="sidebar-tab-panel"
id="sidebar-panel-ai"
role="tabpanel"
aria-labelledby="sidebar-tab-ai"
>
<div id="ai-history-list" class="recent-list"></div>
<div id="ai-history-empty" class="recent-empty hidden">
<i
data-lucide="messages-square"
style="width: 32px; height: 32px"
></i>
<span>Aucune conversation</span>
</div>
</div>
</aside>
<!-- Sidebar resize handle -->
+505 -68
View File
@@ -114,6 +114,9 @@ class BooksLM {
this._adhocDirs = [];
this._images = [];
this._activeSkill = null;
// Deep Research: a skill-like chip that enables agent tools and injects a
// research directive into the request (without polluting the composer).
this._activeDeepResearch = false;
this._skills = [];
this._menuItems = [];
this._menuIndex = 0;
@@ -129,6 +132,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 +178,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 &&
@@ -203,8 +217,9 @@ class BooksLM {
this._adhocDirs = [];
this._images = [];
this._activeSkill = null;
this._activeDeepResearch = false;
this._isLoading = true;
this._loadHistory();
await this._loadHistory(preferredSessionId);
if (!this._panel) {
this._panel = this._render();
@@ -243,6 +258,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 +346,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 +923,15 @@ class BooksLM {
<div class="bookslm-mention-menu hidden" role="listbox" aria-label="${t('ai.add_context')}"></div>
</div>
<div class="bookslm-input-area">
<button class="bookslm-btn-attach" title="${t('ai.attach_image')}" aria-label="${t('ai.attach_image')}"><i data-lucide="image-plus" style="width:18px;height:18px"></i></button>
<button class="bookslm-btn-plus" title="${t('bookslm.add_options')}" aria-label="${t('bookslm.add_options')}"><i data-lucide="plus" style="width:18px;height:18px"></i></button>
<input type="file" class="bookslm-file-input" accept="image/*" multiple hidden>
<input type="file" class="bookslm-files-input" multiple hidden>
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('ai.composer_label')}"></textarea>
<button class="bookslm-btn-send" title="${t('bookslm.send')}" aria-label="${t('bookslm.send')}">✈️</button>
<button class="bookslm-btn-send" title="${t('bookslm.send')}" aria-label="${t('bookslm.send')}">
<i data-lucide="arrow-up" style="width:18px;height:18px"></i>
</button>
</div>
<div class="bookslm-ext-menu hidden"></div>
`;
// Provider/model picker lives on its own toolbar row so the header
@@ -970,16 +1013,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', () => {
@@ -1162,7 +1220,7 @@ class BooksLM {
const host = this._panel.querySelector('.bookslm-attachments');
if (!host) return;
host.innerHTML = '';
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill;
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill || this._activeDeepResearch;
host.classList.toggle('empty', !hasAny);
if (!hasAny) return;
@@ -1174,6 +1232,13 @@ class BooksLM {
() => { this._activeSkill = null; this._renderAttachments(); },
));
}
if (this._activeDeepResearch) {
host.appendChild(this._chip(
`🔎 ${t('bookslm.ext_deep_research')}`,
'deep-research',
() => { this._activeDeepResearch = false; this._renderAttachments(); },
));
}
for (const file of this._adhocFiles) {
host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path)));
}
@@ -1263,6 +1328,147 @@ 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 = `
<i data-lucide="${entry.icon}" style="width:16px;height:16px"></i>
<span class="bookslm-ext-label">${entry.label}</span>
${entry.disabled ? '<span class="bookslm-ext-soon">' + t('bookslm.soon') + '</span>' : ''}
<span class="bookslm-ext-hint">${entry.hint}</span>
`;
item.addEventListener('click', (e) => {
// BUG-048 — Keep the event from reaching the panel's click handler,
// which would hide a mention/command menu opened by this action right
// after it opens (the menu is rendered asynchronously).
e.stopPropagation();
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/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */
_startDeepResearch() {
this._closeExtMenu();
if (!this._agentMode) this._toggleAgentMode();
this._activeDeepResearch = true;
this._renderAttachments();
showToast(t('bookslm.deep_research_started'), 'info');
}
_onPaste(e) {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
@@ -2525,7 +2731,7 @@ class BooksLM {
img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type }
)),
skill: this._activeSkill,
message: text,
message: this._activeDeepResearch ? `${t('bookslm.deep_research_prompt')}\n\n${text}` : text,
conversation_history: history,
provider,
model,
@@ -2978,10 +3184,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 +3198,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 +3225,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 +3283,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 +3503,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 +3596,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 +3632,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();
+152 -3
View File
@@ -1,7 +1,7 @@
// config.js — extracted from app.js (3872-4865)
import { api, AuthManager, initMfaSettings } from './auth.js';
import { state } from './state.js';
import { el, icon, openFile } from './viewer.js';
import { el, icon, openFile, filterSavedSearches } from './viewer.js';
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
@@ -11,6 +11,9 @@ import { getModelCapabilities, renderCapabilityList, refreshAIPickers } from './
let _recentTimestampTimer = null;
let _recentFilesCache = [];
let _recentRefreshTimer = null;
let _recentQuery = "";
let _aiSessionsCache = [];
let _aiQuery = "";
export async function loadRecentFiles(vaultFilter) {
const listEl = document.getElementById("recent-list");
@@ -22,7 +25,7 @@ export async function loadRecentFiles(vaultFilter) {
try {
const data = await api(url);
_recentFilesCache = data.files || [];
renderRecentList(_recentFilesCache);
renderRecentList(_applyRecentFilter(_recentFilesCache));
} catch (err) {
console.error("Failed to load recent files:", err);
listEl.innerHTML = "";
@@ -39,6 +42,9 @@ function renderRecentList(files) {
listEl.innerHTML = "";
if (!files || files.length === 0) {
if (_recentFilesCache.length && _recentQuery) {
listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("sidebar.no_results"))]));
}
if (emptyEl) {
emptyEl.classList.remove("hidden");
safeCreateIcons();
@@ -147,6 +153,125 @@ 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 "";
}
}
/** Accent- and case-insensitive normalization for the sidebar filters (#98/#99). */
function _sidebarNorm(value) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
/** Apply the current sidebar query to the recent-files list (#99). */
function _applyRecentFilter(files) {
const q = _sidebarNorm(_recentQuery);
if (!q) return files;
return (files || []).filter((f) => {
const tags = Array.isArray(f.tags) ? f.tags.join(" ") : "";
return _sidebarNorm(f.title).includes(q)
|| _sidebarNorm(f.path).includes(q)
|| _sidebarNorm(f.vault).includes(q)
|| _sidebarNorm(f.preview).includes(q)
|| _sidebarNorm(tags).includes(q);
});
}
/** Filter the sidebar recent-files list by the sidebar filter input (#99). */
export function filterRecentFiles(query) {
_recentQuery = (query || "").trim();
renderRecentList(_applyRecentFilter(_recentFilesCache));
}
/** Apply the current sidebar query to a session list, resolving the mode label. */
function _applyAIFilter(sessions) {
const q = _sidebarNorm(_aiQuery);
if (!q) return sessions;
return (sessions || []).filter((s) => {
const modeLabel = t("bookslm.mode_" + (s.mode || "general"));
return _sidebarNorm(s.title).includes(q)
|| _sidebarNorm(s.preview).includes(q)
|| _sidebarNorm(s.directory).includes(q)
|| _sidebarNorm(s.context).includes(q)
|| _sidebarNorm(modeLabel).includes(q);
});
}
/** Filter the sidebar AI history list by the sidebar filter input (#98). */
export function filterAIHistory(query) {
_aiQuery = (query || "").trim();
renderAIHistoryList(_applyAIFilter(_aiSessionsCache));
}
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);
}
_aiSessionsCache = sessions;
renderAIHistoryList(_applyAIFilter(_aiSessionsCache));
}
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 (_aiSessionsCache.length && _aiQuery) {
listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("bookslm.history_no_match"))]));
}
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 +279,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,20 +298,40 @@ 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: t("sidebar.filter_recent"),
saved: t("sidebar.filter_saved"),
ai: t("sidebar.filter_ai"),
};
filterInput.placeholder = placeholders[tab] || "";
}
const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : "";
if (query) {
if (tab === "vaults") performTreeSearch(query);
else if (tab === "tags") filterTagCloud(query);
else if (tab === "recent") filterRecentFiles(query);
else if (tab === "saved") filterSavedSearches(query);
else if (tab === "ai") filterAIHistory(query);
}
// Auto-load recent files when switching to the recent tab
if (tab === "recent") {
_populateRecentVaultFilter();
if (filterInput) filterRecentFiles(filterInput.value.trim());
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
// #99 — Re-apply the current sidebar query on the saved-searches tab.
if (tab === "saved" && filterInput) {
filterSavedSearches(filterInput.value.trim());
}
// #96/#98 — Auto-load the AI conversation history when entering its tab,
// re-applying the current sidebar filter query.
if (tab === "ai") {
if (filterInput) _aiQuery = filterInput.value.trim();
loadAISessionList();
}
}
function initHelpModal() {
+11
View File
@@ -69,6 +69,17 @@ export function isInlineEditorActive() {
return !!(container && container.classList.contains(INLINE_CONTAINER_CLASS));
}
/**
* Query an element inside the editor container, wherever it currently lives
* (modal or content area). Use it instead of `modal.querySelector(...)` for the
* header/footer/brand, which move with the container in inline mode (#93).
*/
export function queryEditor(selector) {
const container = getEditorContainer();
const scope = container || getEditorModal();
return scope ? scope.querySelector(selector) : null;
}
/**
* Move the editor container into `area`, replacing the rendered document.
* @returns {boolean} true when the container was mounted inline.
+4 -1
View File
@@ -10,7 +10,7 @@
*/
import { state } from './state.js';
import { api } from './auth.js';
import { escapeHtml, getFileIcon, safeCreateIcons } from './utils.js';
import { escapeHtml, getFileIcon, safeCreateIcons, detachInlineEditor } from './utils.js';
const PANE_MAX = 4;
@@ -154,6 +154,9 @@ function createPaneTabManager(paneId) {
async activate(tabId) {
if (this._activeTabId === tabId && this._tabs.length > 0) return;
// #93 — an inline edition session owns this pane's content area: release
// it before the load placeholder / read view wipes it off the DOM.
detachInlineEditor();
if (this._activeTabId && this._tabCache[this._activeTabId])
this._saveCurrentTabState();
+24 -27
View File
@@ -2,8 +2,8 @@ import { state } from './state.js';
import { safeCreateIcons, getFileIcon, flushIcons } from './utils.js';
import { api } from './auth.js';
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
import { _populateRecentVaultFilter, switchSidebarTab } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText } from './viewer.js';
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
import { performAdvancedSearch } from './search.js';
import { t } from './i18n.js';
@@ -700,25 +700,32 @@ function initSidebarFilter() {
const caseBtn = document.getElementById("sidebar-filter-case-btn");
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
// Route the query to the active tab's own filter (#98/#99).
const routeFilter = async (q) => {
const tab = state.activeSidebarTab;
if (tab === "vaults") await performTreeSearch(q);
else if (tab === "recent") filterRecentFiles(q);
else if (tab === "saved") filterSavedSearches(q);
else if (tab === "ai") filterAIHistory(q);
else filterTagCloud(q);
};
const routeClear = async () => {
const tab = state.activeSidebarTab;
if (tab === "vaults") await restoreSidebarTree();
else if (tab === "recent") filterRecentFiles("");
else if (tab === "saved") filterSavedSearches("");
else if (tab === "ai") filterAIHistory("");
else filterTagCloud("");
};
input.addEventListener("input", () => {
const hasText = input.value.length > 0;
clearBtn.style.display = hasText ? "flex" : "none";
clearTimeout(state.filterDebounce);
state.filterDebounce = setTimeout(async () => {
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
if (hasText) {
if (state.activeSidebarTab === "vaults") {
await performTreeSearch(q);
} else {
filterTagCloud(q);
}
} else {
if (state.activeSidebarTab === "vaults") {
await restoreSidebarTree();
} else {
filterTagCloud("");
}
}
if (hasText) await routeFilter(q);
else await routeClear();
}, 220);
});
@@ -726,13 +733,7 @@ function initSidebarFilter() {
state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive;
caseBtn.classList.toggle("active");
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
if (input.value.trim()) {
if (state.activeSidebarTab === "vaults") {
await performTreeSearch(q);
} else {
filterTagCloud(q);
}
}
if (input.value.trim()) await routeFilter(q);
});
clearBtn.addEventListener("click", async () => {
@@ -741,11 +742,7 @@ function initSidebarFilter() {
state.sidebarFilterCaseSensitive = false;
caseBtn.classList.remove("active");
clearTimeout(state.filterDebounce);
if (state.activeSidebarTab === "vaults") {
await restoreSidebarTree();
} else {
filterTagCloud("");
}
await routeClear();
});
clearBtn.style.display = "none";
+6 -2
View File
@@ -6,7 +6,7 @@ import { api, AuthManager } from './auth.js';
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
import { loadRecentFiles, initAboutModal } from './config.js';
import { openFile } from './viewer.js';
import { closeEditor } from './utils.js';
import { closeEditor, reloadExternalWrite } from './utils.js';
import { initOffline, syncFileIndexFromServer } from './offline.js';
import { initThemes } from './themes.js';
@@ -166,7 +166,11 @@ export const IndexUpdateManager = (() => {
if (state.currentVault && state.currentPath) {
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
if (changed) {
openFile(state.currentVault, state.currentPath);
// #93 — an inline edition session (or Forge) owns the content area:
// re-render the read view below it would destroy the editor mid-
// session. Reload the editor buffer from disk instead (same path as
// an external AI write).
reloadExternalWrite(state.currentVault, state.currentPath);
}
}
} catch (err) {
+6 -2
View File
@@ -2,7 +2,7 @@
import { api, AuthManager, initLoginForm } from './auth.js';
import { state } from './state.js';
import { openFile, showWelcome, renderFile, el } from './viewer.js';
import { safeCreateIcons, getFileIcon, escapeHtml } from './utils.js';
import { safeCreateIcons, getFileIcon, escapeHtml, detachInlineEditor } from './utils.js';
import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js';
import { GraphViewManager } from './graph.js';
import { DashboardConflictsWidget } from './dashboard.js';
@@ -2014,7 +2014,11 @@ export const TabManager = {
/** Activate a specific tab */
async activate(tabId) {
if (this._activeTabId === tabId && this._tabs.length > 0) return;
// #93 — an inline edition session owns this content area: release it before
// the load placeholder / read view wipes it off the DOM.
detachInlineEditor();
// Save current tab state
if (this._activeTabId && this._tabCache[this._activeTabId]) {
this._saveCurrentTabState();
+14 -13
View File
@@ -6,9 +6,11 @@ import { createAIToolbar } from './ai.js';
import { startCollab, stopCollab } from './collab.js';
import {
getInlineHostArea,
getEditorContainer,
mountEditorInline,
unmountEditorInline,
isInlineEditorActive,
queryEditor,
} from './editor-inline.js';
// ---------------------------------------------------------------------------
@@ -372,9 +374,9 @@ async function openEditor(vaultName, filePath) {
const bodyEl = document.getElementById("editor-body");
// Restore header/footer (may be hidden by Forge)
var headerEl = modal.querySelector(".editor-header");
var footerEl = modal.querySelector(".editor-footer");
var container = modal.querySelector(".editor-container");
var headerEl = queryEditor(".editor-header");
var footerEl = queryEditor(".editor-footer");
var container = getEditorContainer();
if (headerEl) headerEl.style.display = "";
if (footerEl) footerEl.style.display = "";
if (container) container.style.maxHeight = "";
@@ -580,14 +582,6 @@ function closeEditor() {
const modal = document.getElementById("editor-modal");
if (!modal) return;
modal.classList.remove("active");
// Restore brand text
var brand = modal.querySelector('.editor-brand');
if (brand) brand.textContent = 'ObsiGate';
// Restore header/footer (may be hidden by Forge)
var headerEl = modal.querySelector(".editor-header");
var footerEl = modal.querySelector(".editor-footer");
if (headerEl) headerEl.style.display = "";
if (footerEl) footerEl.style.display = "";
stopCollab();
if (state.editorView) {
state.editorView.destroy();
@@ -598,9 +592,16 @@ function closeEditor() {
// Remove forge iframe if present
var forgeFrame = document.getElementById("forge-iframe");
if (forgeFrame) forgeFrame.remove();
// #93 — give the editor container back to the modal; an inline session also
// releases the content area, whose read view must be re-rendered.
// #93 — hand the editor container back to the modal BEFORE restoring the
// header/footer/brand: they live inside the container and therefore travel
// with it in inline mode (a `modal.querySelector` would miss them).
const host = unmountEditorInline();
var brand = queryEditor('.editor-brand');
if (brand) brand.textContent = 'ObsiGate';
var headerEl = queryEditor(".editor-header");
var footerEl = queryEditor(".editor-footer");
if (headerEl) headerEl.style.display = "";
if (footerEl) footerEl.style.display = "";
_invalidateActiveTabCache(vault, path);
state.editorVault = null;
state.editorPath = null;
+36 -4
View File
@@ -2,7 +2,7 @@
import { api, AuthManager } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
import { isInlineEditorActive } from './editor-inline.js';
import { isInlineEditorActive, queryEditor } from './editor-inline.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
@@ -768,8 +768,9 @@ export function renderFile(data) {
state.editorVault = data.vault;
state.editorPath = data.path;
}
// Update header: show "Forge" brand + filename
var brand = modal.querySelector(".editor-brand");
// Update header: show "Forge" brand + filename (the header travels with the
// container in inline mode — query it wherever it lives, #93)
var brand = queryEditor(".editor-brand");
var titleInp = document.getElementById("editor-title-input");
var fileInp = document.getElementById("editor-filename-input");
if (brand) brand.textContent = t("viewer.forge_brand");
@@ -1810,6 +1811,14 @@ function _renderSyncPanel(panel) {
// ── Saved searches filter ──
var _savedFilterType = 'all';
var _savedFilterInitDone = false;
var _savedQuery = '';
function _savedNorm(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function _initSavedFilter() {
if (_savedFilterInitDone) return;
@@ -1826,18 +1835,41 @@ function _initSavedFilter() {
_savedFilterInitDone = true;
}
/** #99 — Filter the saved-searches list by the global sidebar filter input. */
export function filterSavedSearches(query) {
_savedQuery = (query || '').trim();
_applySavedFilter();
}
function _applySavedFilter() {
var items = document.querySelectorAll('.saved-search-item');
var list = document.getElementById('saved-searches-list');
var q = _savedNorm(_savedQuery);
var hasVisible = false;
items.forEach(function(item) {
var type = item.dataset.type;
var show = _savedFilterType === 'all' || type === _savedFilterType;
var typeOk = _savedFilterType === 'all' || type === _savedFilterType;
var textOk = !q || _savedNorm(item.textContent).includes(q);
var show = typeOk && textOk;
item.style.display = show ? '' : 'none';
if (show) hasVisible = true;
});
// Remove a previous "no match" hint before deciding whether to add one.
if (list) {
var prev = list.querySelector('.sidebar-filter-empty');
if (prev) prev.remove();
}
// Show/hide empty state
var empty = document.getElementById('saved-searches-empty');
if (empty) empty.style.display = hasVisible ? 'none' : '';
// Explain an empty result caused by the global search rather than by an
// actually empty saved-searches list.
if (!hasVisible && q && list && items.length) {
var hint = document.createElement('div');
hint.className = 'sidebar-filter-empty';
hint.textContent = t('sidebar.no_results');
list.appendChild(hint);
}
}
+26
View File
@@ -1544,6 +1544,9 @@
"sidebar.expand_collapse": "Expand/Collapse",
"sidebar.file_icons": "Icons",
"sidebar.filter_instant": "Instant search",
"sidebar.filter_ai": "Filter AI history...",
"sidebar.filter_recent": "Filter recent files...",
"sidebar.filter_saved": "Filter saved searches...",
"sidebar.filter_path": "Path filters",
"sidebar.filter_placeholder": "Filter files...",
"sidebar.filter_results_grouped": "Grouped results",
@@ -1826,9 +1829,32 @@
"bookslm.session_history": "Session history",
"bookslm.session_delete": "Delete session",
"bookslm.history_empty": "No saved session",
"bookslm.history_no_match": "No conversation matches the search",
"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 enabled — add your question and send.",
"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",
+26
View File
@@ -1544,6 +1544,9 @@
"sidebar.expand_collapse": "Développer/Réduire",
"sidebar.file_icons": "Icônes",
"sidebar.filter_instant": "Recherche instantanée",
"sidebar.filter_ai": "Filtrer l'historique IA...",
"sidebar.filter_recent": "Filtrer les fichiers récents...",
"sidebar.filter_saved": "Filtrer les recherches sauvegardées...",
"sidebar.filter_path": "Filtres de chemin",
"sidebar.filter_placeholder": "Filtrer fichiers...",
"sidebar.filter_results_grouped": "Résultats groupés",
@@ -1826,9 +1829,32 @@
"bookslm.session_history": "Historique des sessions",
"bookslm.session_delete": "Supprimer la session",
"bookslm.history_empty": "Aucune session enregistrée",
"bookslm.history_no_match": "Aucune conversation ne correspond à la recherche",
"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 activé — ajoutez votre question puis envoyez.",
"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",
+27 -4
View File
@@ -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); }
@@ -9672,6 +9676,7 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
.bookslm-chip-remove:hover { color: #f87171; }
.bookslm-chip-thumb { width: 18px; height: 18px; object-fit: cover; border-radius: 4px; }
.bookslm-chip-skill { border-color: var(--accent); }
.bookslm-chip-deep-research { border-color: var(--accent); }
/* Composer menus (`/` commands and `@` mentions). */
.bookslm-menu-layer { position: relative; }
.bookslm-command-menu, .bookslm-mention-menu { position: absolute; left: 12px; right: 12px; bottom: 4px;
@@ -9692,11 +9697,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-input-area button.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-input-area button.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; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.3.0",
"version": "2.6.1",
"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": {
+1
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
"""Vérifie l'état du dernier run CI Gitea d'ObsiGate (lecture seule)."""
import json
import subprocess
import sys
import urllib.request
REPO = "Projets/ObsiGate"
BASE = f"https://git.dracodev.net/api/v1/repos/{REPO}"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"
cred = subprocess.run(
["git", "credential", "fill"],
input="protocol=https\nhost=git.dracodev.net\n\n",
capture_output=True,
text=True,
cwd=r"C:\dev\git\python\ObsiGate",
)
token = ""
for line in cred.stdout.splitlines():
if line.startswith("password"):
token = line.split("=", 1)[1]
if not token:
raise SystemExit("pas de jeton git disponible")
sha = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=r"C:\dev\git\python\ObsiGate",
).stdout.strip()
def get(path: str):
req = urllib.request.Request(
f"{BASE}{path}",
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
"User-Agent": UA,
},
)
with urllib.request.urlopen(req, timeout=25) as resp:
return json.load(resp)
runs = get("/actions/runs?limit=10").get("workflow_runs", [])
print(f"HEAD local : {sha[:8]} — {len(runs)} run(s) récent(s)")
target = None
for run in runs:
head = str(run.get("head_sha") or "")
title = run.get("display_title") or run.get("name") or "?"
flag = " <== HEAD" if head.startswith(sha[:8]) else ""
print(f" #{run.get('id')} {title[:60]} | {run.get('status')}/{run.get('conclusion')} | {head[:8]}{flag}")
if head.startswith(sha[:8]) and target is None:
target = run
if target is None:
print("Aucun run pour le HEAD courant (encore en file d'attente ?)")
sys.exit(0)
jobs_resp = get(f"/actions/runs/{target['id']}/jobs")
print(f"run #{target['id']} : status={target.get('status')} conclusion={target.get('conclusion')} "
f"started={target.get('run_started_at')} updated={target.get('updated_at')}")
print(f"clés jobs: {list(jobs_resp)[:6]}")
for job in jobs_resp.get("workflow_jobs", jobs_resp.get("jobs", [])) or []:
print(f" - {job.get('name')}: {job.get('status')}/{job.get('conclusion')}")
if job.get("status") != "completed":
for step in job.get("steps", []) or []:
print(f" · {step.get('name')}: {step.get('status')}/{step.get('conclusion')}")
try:
runners = get("/actions/runners")
entries = runners.get("runners", runners) if isinstance(runners, dict) else runners
print(f"runners: {len(entries) if isinstance(entries, list) else entries}")
if isinstance(entries, list):
for runner in entries:
print(f" - {runner.get('name')} status={runner.get('status')} labels={runner.get('labels')}")
except Exception as exc: # noqa: BLE001
print(f"runners indisponibles: {exc}")
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* ObsiGate - JSDOM tests for the AI history sidebar filter (#98).
*
* Covers the client-side search over the AI conversation history rendered in
* the left sidebar "Historique IA" tab:
* - sessions are loaded and rendered through `loadAISessionList()`
* - `filterAIHistory()` narrows the list by title / preview / directory /
* context / mode label, case- and accent-insensitively
* - an empty match shows a "no match" hint instead of the empty state
*
* Usage: node tests/frontend/ai-sidebar.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
const dom = new JSDOM(
`<!DOCTYPE html>
<html><body>
<aside class="sidebar">
<input id="sidebar-filter-input" value="" />
<button id="sidebar-filter-case-btn"></button>
<button id="sidebar-filter-clear-btn"></button>
<div id="ai-history-list" class="recent-list"></div>
<div id="ai-history-empty" class="recent-empty hidden">
<i data-lucide="messages-square"></i><span>Aucune conversation</span>
</div>
</aside>
</body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true },
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.KeyboardEvent = w.KeyboardEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
globalThis.MutationObserver = w.MutationObserver;
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
passCount++;
console.log(` ✓ ${name}`);
} catch (err) {
console.error(` ✗ ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
const SESSIONS = [
{ id: "s1", title: "Pizza maison", mode: "general", updatedAt: 1, preview: "La pâte à pizza se lève toute la nuit" },
{ id: "s2", title: "Recette café au lait", mode: "directory", directory: "Recettes", updatedAt: 2 },
{ id: "s3", title: "Plan du cours", mode: "documents", context: "documents-books", updatedAt: 3 },
];
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/bookslm/history")) {
return { ok: true, status: 200, json: async () => ({ sessions: SESSIONS }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const mod = await import(
pathToFileURL(path.join(JS_DIR, "config.js")).href
);
const { loadAISessionList, filterAIHistory } = mod;
const listEl = () => document.getElementById("ai-history-list");
const items = () => Array.from(listEl().querySelectorAll(".recent-item"));
console.log("AI history sidebar filter (#98) — config.js");
await test("loadAISessionList renders every session", async () => {
await loadAISessionList();
assert.equal(items().length, 3, "three sessions rendered");
});
await test("filter narrows by title, case-insensitively", async () => {
filterAIHistory("pizza");
assert.equal(items().length, 1, "one session matches 'pizza'");
filterAIHistory("PLAN");
assert.equal(items().length, 1, "case-insensitive match on 'PLAN'");
});
await test("filter matches accents-insensitively", async () => {
filterAIHistory("cafe");
assert.equal(items().length, 1, "'cafe' matches 'café au lait'");
const titles = items().map((el) => el.textContent);
assert.ok(titles.some((t) => t.includes("café")), "the café session is the one kept");
});
await test("filter matches the preview and the directory", async () => {
filterAIHistory("pâte");
assert.equal(items().length, 1, "'pâte' matches the preview text");
filterAIHistory("recettes");
assert.equal(items().length, 1, "'recettes' matches the session directory");
});
await test("clearing the query restores the full list", async () => {
filterAIHistory("");
assert.equal(items().length, 3, "no query keeps everything");
});
await test("an unmatched query shows a no-match hint, not the empty state", async () => {
filterAIHistory("zzz-inexistant");
assert.equal(items().length, 0, "no item left");
assert.ok(listEl().querySelector(".sidebar-filter-empty"),
"a 'no match' hint is rendered in the list box");
const emptyEl = document.getElementById("ai-history-empty");
assert.ok(emptyEl.classList.contains("hidden") === false, "empty state is also visible");
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
}
+172 -16
View File
@@ -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");
});
@@ -1344,10 +1363,23 @@ async function main() {
// ── 39. Accented `@` mentions + all-vault fallback (BUG-008) ──
await test("mention menu accepts accents and searches all vaults without a vault", async () => {
localStorage.clear();
let lastUrl = "";
document.body.replaceChildren();
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
const prevVaults = stateMod.state.allVaults;
const prevSelected = stateMod.state.selectedContextVault;
const prevCurrent = stateMod.state.currentVault;
stateMod.state.allVaults = [];
stateMod.state.selectedContextVault = null;
stateMod.state.currentVault = null;
// Collect every request: `new BooksLM()` also hydrates the AI history
// asynchronously, so the *last* URL is not necessarily the tree-search one.
const urls = [];
const sawTreeSearchAll = () => urls.some(
(u) => u.includes("/api/tree-search") && u.includes("vault=all"),
);
globalThis.fetch = async (url) => {
lastUrl = String(url);
if (lastUrl.includes("/api/tree-search")) {
urls.push(String(url));
if (String(url).includes("/api/tree-search")) {
return {
ok: true, status: 200,
json: async () => ({ results: [{ path: "Café/note.md", type: "file" }] }),
@@ -1363,13 +1395,21 @@ async function main() {
ta.value = "@café";
ta.selectionStart = ta.selectionEnd = 5;
b._onComposerInput();
await sleep(20);
const menu = b._panel.querySelector(".bookslm-mention-menu");
let waited = 0;
while (!sawTreeSearchAll() && waited < 500) {
await sleep(20);
waited += 20;
}
assert.ok(!menu.classList.contains("hidden"), "accented mention keeps the menu open");
assert.ok(lastUrl.includes("vault=all"), "no vault -> searches all vaults");
assert.ok(sawTreeSearchAll(),
`no vault -> searches all vaults (got ${JSON.stringify(urls)})`);
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
b._panel.remove();
localStorage.clear();
stateMod.state.allVaults = prevVaults;
stateMod.state.selectedContextVault = prevSelected;
stateMod.state.currentVault = prevCurrent;
});
// ── 40. Keyboard navigation in the command menu (BUG-007) ──
@@ -1652,10 +1692,10 @@ async function main() {
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
const prev = stateMod.state.allVaults;
stateMod.state.allVaults = [{ name: "FallbackVault" }];
let lastUrl = "";
const urls = [];
globalThis.fetch = async (url) => {
lastUrl = String(url);
if (lastUrl.includes("/files")) {
urls.push(String(url));
if (String(url).includes("/files")) {
return { ok: true, status: 200, json: async () => ({ files: [{ path: "a.md" }] }) };
}
return { ok: true, status: 200, json: async () => ({}) };
@@ -1668,7 +1708,8 @@ async function main() {
await b._showMentionMenu("");
const menu = b._panel.querySelector(".bookslm-mention-menu");
assert.ok(!menu.classList.contains("hidden"), "empty-query menu is shown");
assert.ok(lastUrl.includes("FallbackVault"), "uses the fallback vault");
assert.ok(urls.some((u) => u.includes("/files") && u.includes("FallbackVault")),
`uses the fallback vault (got ${JSON.stringify(urls)})`);
stateMod.state.allVaults = prev;
b._panel.remove();
localStorage.clear();
@@ -1783,6 +1824,121 @@ async function main() {
assert.equal(AuthManager.getToken(), null, "session cleared");
});
// ── BUG-048: the "+" panel menus must stay open (contexts @ / skills /) ──
await test("ext menu opens the @ mention menu and keeps it visible", async () => {
localStorage.clear();
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
const prevVaults = stateMod.state.allVaults;
stateMod.state.allVaults = [{ name: "V" }];
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [{ id: "research", label: "Recherche", type: "skill", description: "d" }],
commands: [],
}),
};
}
if (u.includes("/paths")) {
return {
ok: true, status: 200,
json: async () => ({ vault: "V", count: 1, results: [
{ vault: "V", path: "notes/a.md", name: "a.md", type: "file" },
] }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = ta.selectionStart = ta.selectionEnd = 0;
b._toggleExtMenu();
const mentionMenu = b._panel.querySelector(".bookslm-mention-menu");
const contextBtn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="context"]');
assert.ok(contextBtn, "Contextes entry present in the + panel");
contextBtn.click();
await sleep(20);
assert.ok(!mentionMenu.classList.contains("hidden"),
"mention menu stays open after picking Contextes (BUG-048)");
assert.ok(mentionMenu.querySelectorAll(".bookslm-menu-item").length >= 1,
"mention menu lists entries");
b._panel.remove();
localStorage.clear();
stateMod.state.allVaults = prevVaults;
});
await test("ext menu opens the / command menu and keeps it visible", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [{ id: "research", label: "Recherche", type: "skill", description: "d" }],
commands: [],
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = ta.selectionStart = ta.selectionEnd = 0;
b._toggleExtMenu();
const commandMenu = b._panel.querySelector(".bookslm-command-menu");
const skillBtn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="skill"]');
assert.ok(skillBtn, "Skills entry present in the + panel");
skillBtn.click();
await sleep(20);
assert.ok(!commandMenu.classList.contains("hidden"),
"command menu stays open after picking Skills (BUG-048)");
assert.ok(commandMenu.querySelectorAll(".bookslm-menu-item").length >= 1,
"command menu lists skills");
b._panel.remove();
localStorage.clear();
});
await test("the + button carries a '+' icon", async () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const plus = b._panel.querySelector(".bookslm-btn-plus i");
assert.ok(plus, "the + button shows an icon");
assert.equal(plus.getAttribute("data-lucide"), "plus", "the + button icon is a plus");
b._panel.remove();
});
await test("ext menu Deep Research adds a chip instead of composer text", async () => {
localStorage.clear();
document.body.replaceChildren();
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = "";
b._toggleExtMenu();
const btn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="deep_research"]');
assert.ok(btn, "Deep Research entry present in the + panel");
btn.click();
await sleep(20);
const chip = b._panel.querySelector(".bookslm-chip-deep-research");
assert.ok(chip, "a Deep Research chip is added");
assert.equal(ta.value, "", "the composer stays empty (no injected directive)");
assert.equal(b._activeDeepResearch, true, "the deep-research flag is set");
// Removing the chip clears the flag.
chip.querySelector(".bookslm-chip-remove").click();
assert.ok(!b._panel.querySelector(".bookslm-chip-deep-research"), "the chip is removed");
assert.equal(b._activeDeepResearch, false, "the flag is cleared with the chip");
b._panel.remove();
localStorage.clear();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
+61 -3
View File
@@ -67,6 +67,7 @@ const {
isInlineEditorActive,
mountEditorInline,
unmountEditorInline,
queryEditor,
} = mod;
let testCount = 0;
@@ -161,6 +162,17 @@ test("unmount without an inline session is a no-op", () => {
assert.equal(container().parentElement, modal());
});
test("queryEditor follows the container (modal → content area)", () => {
reset();
assert.equal(queryEditor(".editor-brand").textContent, "ObsiGate");
mountEditorInline(area());
assert.ok(queryEditor(".editor-brand"), "brand reachable while mounted inline");
assert.equal(document.getElementById("editor-modal").querySelector(".editor-brand"), null, "brand really left the overlay");
unmountEditorInline();
assert.ok(document.getElementById("editor-modal").querySelector(".editor-brand"), "brand back in the overlay");
reset();
});
test("host area follows the active pane in split view", () => {
reset();
const paneArea = document.createElement("div");
@@ -193,12 +205,37 @@ const forgeSrc = read("frontend", "editor-poc.html");
console.log("Inline edition (#93) — wiring");
test("utils.js / viewer.js import every editor-inline helper they call", () => {
const helpers = ["getEditorContainer", "getInlineHostArea", "isInlineEditorActive", "mountEditorInline", "unmountEditorInline", "queryEditor"];
const strip = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
for (const [name, raw] of [["utils.js", utilsSrc], ["viewer.js", viewerSrc]]) {
const src = strip(raw);
const marker = "from './editor-inline.js';";
const end = raw.indexOf(marker);
const start = end === -1 ? -1 : raw.lastIndexOf("import {", end);
const imported =
end === -1 || start === -1
? []
: raw
.slice(start + "import {".length, end)
.replace(/\}/g, "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const helper of helpers) {
if (new RegExp(`\\b${helper}\\(`).test(src)) {
assert.ok(imported.includes(helper), `${name} calls ${helper}() without importing it`);
}
}
}
});
test("index.html gives the editor container a stable id", () => {
assert.match(indexSrc, /<div class="editor-container" id="editor-container">/);
});
test("viewer.js mounts the editor inline when rendering is taken over", () => {
assert.match(viewerSrc, /import \{ isInlineEditorActive \} from '\.\/editor-inline\.js';/);
assert.ok(viewerSrc.includes("import { isInlineEditorActive, queryEditor } from './editor-inline.js';"));
const guard = viewerSrc.match(/export function renderFile\(data\) \{([\s\S]*?)\n const area = getContentArea\(\);/);
assert.ok(guard, "renderFile head not found");
assert.match(guard[1], /if \(isInlineEditorActive\(\)\) detachInlineEditor\(\);/);
@@ -206,6 +243,8 @@ test("viewer.js mounts the editor inline when rendering is taken over", () => {
test("viewer.js Forge button mounts inline and sizes the iframe accordingly", () => {
assert.match(viewerSrc, /var inline = activateInlineEditor\(data\.vault, data\.path\);/);
assert.ok(viewerSrc.includes('var brand = queryEditor(".editor-brand");'), "header query must follow the container");
assert.equal(viewerSrc.includes('modal.querySelector(".editor-brand")'), false, "modal-scoped brand query would break inline mode");
assert.match(
viewerSrc,
/iframe\.style\.cssText = "width:100%;height:" \+ \(inline \? "100%" : "82vh"\) \+ ";border:none;display:block;";/,
@@ -245,12 +284,32 @@ test("utils.js reloads the displayed document after an AI write", () => {
});
test("sync.js forge-close goes through the shared close path", () => {
assert.match(syncSrc, /import \{ closeEditor \} from '\.\/utils\.js';/);
assert.ok(syncSrc.includes("import { closeEditor, reloadExternalWrite } from './utils.js';"), "utils import missing");
const handler = syncSrc.match(/if \(e\.data\.type === 'forge-close'\) \{([\s\S]*?)\n \}/);
assert.ok(handler, "forge-close handler not found");
assert.match(handler[1], /closeEditor\(\);/);
});
test("sync.js keeps an open edition session alive on external file changes", () => {
const sse = syncSrc.match(/const changed = \(data\.changes \|\| \[\]\)([\s\S]*?)\n \}/);
assert.ok(sse, "SSE index_updated refresh block not found");
assert.ok(sse[1].includes("reloadExternalWrite(state.currentVault, state.currentPath);"), "SSE refresh must not re-render the read view");
assert.ok(!/^\s*openFile\(state\.currentVault/m.test(sse[1]), "openFile must not be called directly here");
});
test("tab activation releases an inline edition session first", () => {
const uiActivate = read("frontend", "js", "ui.js").match(/async activate\(tabId\) \{([\s\S]*?)\n \},/);
assert.ok(uiActivate, "TabManager.activate not found");
assert.match(uiActivate[1], /detachInlineEditor\(\);/);
});
test("pane activation releases an inline edition session first", () => {
const paneSrc = read("frontend", "js", "pane-manager.js");
const paneActivate = paneSrc.match(/async activate\(tabId\) \{([\s\S]*?)\n const cache/);
assert.ok(paneActivate, "PaneTabManager.activate not found");
assert.match(paneActivate[1], /detachInlineEditor\(\);/);
});
test("bookslm.js reports the edited document and the assistant writes", () => {
assert.match(bookslmSrc, /const editing = this\._editingDocument\(\);/);
assert.match(bookslmSrc, /if \(editing\) ctx\.editing = editing;/);
@@ -281,7 +340,6 @@ test("style.css lets the inline editor fill the content area", () => {
assert.match(fill[1], /height: auto;/, "must win over the mobile 100vh rule");
assert.match(fill[1], /max-width: none;/);
});
// ── BUG-045: one scroll surface only in the CodeMirror editor ──
test("style.css: #editor-body scrolls through the CodeMirror scroller only", () => {
const body = cssSrc.match(/\.editor-body\.editor-body-cm \{([^}]*)\}/);
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* ObsiGate - JSDOM tests for the sidebar search/filter bar on the
* "Recent" and "Saved searches" tabs (#99).
*
* Covers:
* - `filterRecentFiles()` (config.js): narrows the recent-files list by
* title / path / vault / preview / tags, case- and accent-insensitively
* - `filterSavedSearches()` (viewer.js): narrows the saved-searches list and
* combines with the type pills, with a "no match" hint when empty
*
* Usage: node tests/frontend/sidebar-filters.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
const dom = new JSDOM(
`<!DOCTYPE html>
<html><body>
<aside class="sidebar">
<input id="sidebar-filter-input" value="" />
<button id="sidebar-filter-case-btn"></button>
<button id="sidebar-filter-clear-btn"></button>
<div id="recent-list" class="recent-list"></div>
<div id="recent-empty" class="recent-empty hidden"></div>
<select id="recent-vault-filter"></select>
<div class="saved-filter-bar" id="saved-filter-bar">
<button class="saved-filter-pill active" data-filter="all">Tous</button>
<button class="saved-filter-pill" data-filter="search">Recherches</button>
<button class="saved-filter-pill" data-filter="directory">Repertoires</button>
</div>
<div id="saved-searches-list" class="recent-list"></div>
<div id="saved-searches-empty" class="recent-empty"></div>
</aside>
</body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true },
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.KeyboardEvent = w.KeyboardEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
globalThis.MutationObserver = w.MutationObserver;
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
passCount++;
console.log(` ✓ ${name}`);
} catch (err) {
console.error(` ✗ ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
const RECENT = [
{ vault: "V", path: "Recettes/Pizza.md", title: "Pizza maison", preview: "pâte", tags: ["cuisine"], mtime_human: "il y a 1 h" },
{ vault: "V", path: "Notes/café.md", title: "Café du matin", preview: "arôme", tags: ["journal"], mtime_human: "il y a 2 h" },
{ vault: "W", path: "Projets/plan.md", title: "Plan de projet", preview: "étapes", tags: [], mtime_human: "hier" },
];
const SAVED = [
{ id: "s1", query: "pizza", vault: "V", include_paths: "Recettes", case_sensitive: false },
{ id: "s2", query: "", include_paths: "Recettes", vault: "V" },
{ id: "s3", query: "roadmap", vault: "all" },
];
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/recent")) {
return { ok: true, status: 200, json: async () => ({ files: RECENT }) };
}
if (u.includes("/api/saved-searches")) {
return { ok: true, status: 200, json: async () => SAVED };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const configMod = await import(pathToFileURL(path.join(JS_DIR, "config.js")).href);
const viewerMod = await import(pathToFileURL(path.join(JS_DIR, "viewer.js")).href);
const { loadRecentFiles, filterRecentFiles } = configMod;
const { loadSavedSearches, filterSavedSearches } = viewerMod;
const recentItems = () => Array.from(document.querySelectorAll("#recent-list .recent-item"));
const visibleRecent = () => recentItems().filter((el) => el.style.display !== "none").length;
const savedItems = () => Array.from(document.querySelectorAll(".saved-search-item"));
const visibleSaved = () => savedItems().filter((el) => el.style.display !== "none").length;
console.log("Sidebar filters (#99) — Recent & Saved searches");
await test("loadRecentFiles renders every recent file", async () => {
await loadRecentFiles(null);
assert.equal(recentItems().length, 3, "three recent items rendered");
});
await test("filterRecentFiles narrows by title, case-insensitively", async () => {
filterRecentFiles("pizza");
assert.equal(visibleRecent(), 1, "one recent item matches 'pizza'");
filterRecentFiles("PLAN");
assert.equal(visibleRecent(), 1, "case-insensitive match on 'PLAN'");
});
await test("filterRecentFiles matches accents and tags", async () => {
filterRecentFiles("cafe");
assert.equal(visibleRecent(), 1, "'cafe' matches 'Café du matin'");
filterRecentFiles("journal");
assert.equal(visibleRecent(), 1, "tag match");
});
await test("filterRecentFiles matches the vault and clears", async () => {
filterRecentFiles("Projets");
assert.equal(visibleRecent(), 1, "path match");
filterRecentFiles("");
assert.equal(visibleRecent(), 3, "no query restores everything");
});
await test("filterRecentFiles shows a no-match hint", async () => {
filterRecentFiles("zzz-inexistant");
assert.equal(visibleRecent(), 0, "no item left");
assert.ok(document.querySelector("#recent-list .sidebar-filter-empty"),
"a no-match hint is rendered in the recent list");
filterRecentFiles("");
});
await test("loadSavedSearches renders every saved search", async () => {
await loadSavedSearches();
assert.equal(savedItems().length, 3, "three saved searches rendered");
});
await test("filterSavedSearches narrows by query text", async () => {
filterSavedSearches("pizza");
assert.equal(visibleSaved(), 1, "one saved search matches 'pizza'");
filterSavedSearches("roadmap");
assert.equal(visibleSaved(), 1, "match on the query field");
});
await test("filterSavedSearches combines with the type pills", async () => {
filterSavedSearches("");
assert.equal(visibleSaved(), 3, "all visible before the pill filter");
document.querySelector('.saved-filter-pill[data-filter="directory"]').click();
assert.equal(visibleSaved(), 1, "only the directory search stays visible");
filterSavedSearches("roadmap");
assert.equal(visibleSaved(), 0, "the query excludes the directory search");
assert.ok(document.querySelector("#saved-searches-list .sidebar-filter-empty"),
"a no-match hint is rendered in the saved list");
document.querySelector('.saved-filter-pill[data-filter="all"]').click();
filterSavedSearches("");
assert.equal(visibleSaved(), 3, "clearing both filters restores everything");
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
}
+240
View File
@@ -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"] == []