Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e3240ae2 | ||
|
|
0841778b99 | ||
|
|
788d84a2bf |
@@ -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`,
|
(statut + index), fiche `docs/features/` **ou** `docs/archive/`, `docs/ISSUES_TODOLIST.md`,
|
||||||
guide utilisateur i18n FR/EN + README si impact utilisateur.
|
guide utilisateur i18n FR/EN + README si impact utilisateur.
|
||||||
- **Commit** conventionnel référençant l'ID, puis **push**.
|
- **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`).
|
- Vérifier le **CI Gitea vert** (jobs `lint`, `test`, `security`, `build`, `e2e`).
|
||||||
|
|
||||||
## Cartographie documentaire
|
## 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 |
|
| Sujet | Fichier |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Méthode de livraison / DoD | `docs/DELIVERY_WORKFLOW.md` |
|
| 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` |
|
| Travail à venir + index | `docs/ROADMAP.md` |
|
||||||
| Historique des versions | `CHANGELOG.md` |
|
| Historique des versions | `CHANGELOG.md` |
|
||||||
| Conception par feature | `docs/features/<slug>.md` |
|
| Conception par feature | `docs/features/<slug>.md` |
|
||||||
|
|||||||
+66
-1
@@ -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).
|
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
|
> **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.2**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.4.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,71 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-09-16
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Assistant IA — historique permanent des conversations (#95)** : les échanges sont
|
||||||
|
désormais **persistés côté backend** (`backend/ai_history.py`, `data/ai_history/{user}.json`,
|
||||||
|
cap 200, écriture atomique) et plus seulement en localStorage. Nouveaux endpoints
|
||||||
|
`GET/PUT/DELETE /api/ai/bookslm/history[…]` (liste résumée sans messages → full,
|
||||||
|
upsert qui force l'id, suppression, isolation par utilisateur). Le panneau synchronise
|
||||||
|
ses sessions au chargement (debounce 600 ms, repli hors-ligne sur le cache local),
|
||||||
|
migre les anciennes clés `bookslm-sessions-*` / `bookslm-history-*` et notifie la
|
||||||
|
sidebar via l'événement `bookslm:history-updated`.
|
||||||
|
- **Assistant IA — accès rapide à l'historique dans la sidebar (#96)** : cinquième onglet de
|
||||||
|
navigation `#sidebar-tab-ai` (icône `messages-square`) listant les conversations par
|
||||||
|
ordre chronologique ; un clic ouvre la conversation dans le panneau Assistant IA
|
||||||
|
(`openWithSession`).
|
||||||
|
- **Assistant IA — panneau « + » extensible (#97)** : le bouton « Attach an image » est
|
||||||
|
remplacé par un bouton **« + »** ouvrant un panneau modulaire (registre `_extensions`
|
||||||
|
simple à étendre) : Fichiers, Image, Contextes, Skills, Deep Research (mode agent +
|
||||||
|
prompt pré-rempli), Recherche web et Canva (les deux derniers en « Bientôt »).
|
||||||
|
- **Assistant IA — bouton de soumission arrondi (#94)** : `.bookslm-btn-send` circulaire
|
||||||
|
(40 px), icône Lucide `arrow-up` remplaçant l'avion ✈️.
|
||||||
|
- **Fiche feature** : [docs/features/ai-assistant-history.md](./docs/features/ai-assistant-history.md).
|
||||||
|
Tests : `tests/test_bookslm.py` (+11), `tests/frontend/ai.test.mjs` (+3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.3.4] — 2026-09-16
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **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.2] — 2026-09-16
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+3
-3
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.3.2).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.4.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.3.2 | Dernière mise à jour : Juin 2026*
|
*Projet : ObsiGate | Version : 2.4.0 | Dernière mise à jour : Juin 2026*
|
||||||
|
|||||||
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.3.2).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.4.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.3.2 | Last updated: May 2026*
|
*Project: ObsiGate | Version: 2.4.0 | Last updated: May 2026*
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from backend.agent.loop import run_agent
|
from backend.agent.loop import run_agent
|
||||||
from backend.ai_chat import chat_completion, stream_completion
|
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.auth.middleware import check_vault_access, require_auth
|
||||||
from backend.bookslm import (
|
from backend.bookslm import (
|
||||||
build_general_system_prompt,
|
build_general_system_prompt,
|
||||||
@@ -605,3 +606,86 @@ async def api_bookslm_agent(
|
|||||||
"X-Accel-Buffering": "no",
|
"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)}
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.3.2"
|
version = "2.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.3.2"
|
version = "2.4.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.3.2",
|
"version": "2.4.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -30,8 +30,17 @@ source .venv/bin/activate # Linux/macOS
|
|||||||
# .venv\Scripts\activate # Windows
|
# .venv\Scripts\activate # Windows
|
||||||
|
|
||||||
pip install -r backend/requirements.txt
|
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
|
### 2. Configurer les vaults de test
|
||||||
|
|
||||||
Créez un dossier `test_vault/` (ignoré par `.gitignore`) avec quelques fichiers `.md` :
|
Créez un dossier `test_vault/` (ignoré par `.gitignore`) avec quelques fichiers `.md` :
|
||||||
|
|||||||
@@ -200,7 +200,10 @@ 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-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-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-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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+7
-61
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.3.2 | **Dernière mise à jour :** 2026-09-16
|
> **Version :** 2.4.0 | **Dernière mise à jour :** 2026-09-16
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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
|
- [ ] 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é)
|
- [ ] 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)
|
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
|
||||||
@@ -247,6 +190,10 @@
|
|||||||
| 88 | Assistant IA — contexte applicatif (documents ouverts, répertoire, recherche, fichiers récents) & liens de fichiers fiables (BUG-041, BUG-042) | 2.3.0 | [features/ai-app-context.md](./features/ai-app-context.md) |
|
| 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) |
|
| 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) |
|
| 93 | Édition inline — « Editer » et « Forge » remplacent la vue lecture (assistant IA qui met le document à jour) | 2.3.0 | [features/editeur-inline.md](./features/editeur-inline.md) |
|
||||||
|
| 94 | Assistant IA — Bouton de soumission arrondi + icône renouvelée (arrow-up) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||||
|
| 95 | Assistant IA — Historique permanent des conversations (persistance backend) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||||
|
| 96 | Assistant IA — Accès rapide à l'historique depuis la sidebar de navigation | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||||
|
| 97 | Assistant IA — Panneau « + » extensible (fichiers, Deep Research, contextes, skills…) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -254,13 +201,12 @@
|
|||||||
|
|
||||||
| Priorité | Items | Effort total estimé |
|
| Priorité | Items | Effort total estimé |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93 | ~108 jours réalisés |
|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–97 | ~112 jours réalisés |
|
||||||
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
| 🔵 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 |
|
| ⚪ 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 | #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 |
|
| ⚪ 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** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# #94–#97 — Assistant IA : historique permanent, accès sidebar, bouton rond et panneau « + »
|
||||||
|
|
||||||
|
> **Statut :** ✅ Livré (en attente de validation utilisateur)
|
||||||
|
> **Effort :** ~7 jours | **Impact :** 🟡
|
||||||
|
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md)
|
||||||
|
|
||||||
|
- **Description :** quatre améliorations de l'Assistant IA livrées ensemble :
|
||||||
|
1. **#94** — bouton de soumission **circulaire** avec icône Lucide `arrow-up`
|
||||||
|
(remplace l'avion ✈️).
|
||||||
|
2. **#95** — **historique permanent** des conversations **persisté côté backend**
|
||||||
|
(les échanges survivent aux rechargements de page et aux navigateurs).
|
||||||
|
3. **#96** — accès rapide à l'**historique depuis la sidebar de navigation** (onglet dédié).
|
||||||
|
4. **#97** — panneau **« + » extensible** remplaçant « Attach an image » (modules :
|
||||||
|
fichiers, image, contextes, skills, Deep Research, web, Canva).
|
||||||
|
|
||||||
|
## A. Backend — persistance des conversations (#95) — ✅ livré
|
||||||
|
- [x] **`backend/ai_history.py`** (nouveau) : stockage JSON par utilisateur
|
||||||
|
(`data/ai_history/{username}.json`), écriture atomique (tmp + `shutil.move`),
|
||||||
|
plafond `MAX_SESSIONS = 200` (purge des plus anciennes), tri par `updatedAt` desc.
|
||||||
|
- [x] `list_sessions`, `get_session`, `upsert_session`, `delete_session` ;
|
||||||
|
la liste renvoie des **résumés sans messages** (`_summary` : titre, mode, vault,
|
||||||
|
contexte, `message_count`, `preview`) pour rester légère.
|
||||||
|
- [x] **`backend/bookslm_routes.py`** : modèle Pydantic `BookslmSession` et 4 endpoints :
|
||||||
|
- `GET /api/ai/bookslm/history` — liste des conversations (résumés).
|
||||||
|
- `GET /api/ai/bookslm/history/{session_id}` — conversation complète (404 si absente).
|
||||||
|
- `PUT /api/ai/bookslm/history/{session_id}` — création/mise à jour (l'`id` du corps
|
||||||
|
est forcé à l'`id` du path → jamais d'écriture sous une autre clé).
|
||||||
|
- `DELETE /api/ai/bookslm/history/{session_id}` — suppression (booléen `ok`).
|
||||||
|
- [x] Isolation par utilisateur (`require_auth`) ; saisies défensives (username/id vides) →
|
||||||
|
`[]` ou `None`.
|
||||||
|
|
||||||
|
## B. Frontend — sync serveur + cache local (#95) — ✅ livré
|
||||||
|
- [x] `frontend/js/bookslm.js` : clé local globale `bookslm-sessions-all-{username}` ;
|
||||||
|
**migration** des anciennes clés périmées `bookslm-sessions-<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.
|
||||||
|
|
||||||
|
## 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é.
|
||||||
@@ -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] **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] **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.
|
- [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] **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 ? »).
|
- [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 ? »).
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| ✓ (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) |
|
| ✕ (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 |
|
| 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` |
|
| `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
|
**Repli overlay** : si la cible n'est pas le document affiché (fichier venant d'être créé via la
|
||||||
|
|||||||
@@ -917,6 +917,20 @@
|
|||||||
style="width: 18px; height: 18px"
|
style="width: 18px; height: 18px"
|
||||||
></i>
|
></i>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Vaults panel -->
|
<!-- Vaults panel -->
|
||||||
@@ -1021,6 +1035,23 @@
|
|||||||
<span>Aucune recherche sauvegardee</span>
|
<span>Aucune recherche sauvegardee</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Sidebar resize handle -->
|
<!-- Sidebar resize handle -->
|
||||||
|
|||||||
+496
-66
@@ -129,6 +129,14 @@ class BooksLM {
|
|||||||
this._pathCache = null;
|
this._pathCache = null;
|
||||||
this._pathCacheVault = null;
|
this._pathCacheVault = null;
|
||||||
this._pathCachePromise = 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() {
|
_readAgentMode() {
|
||||||
@@ -167,14 +175,17 @@ class BooksLM {
|
|||||||
* @param {string|null} [opts.vault]
|
* @param {string|null} [opts.vault]
|
||||||
* @param {string} [opts.directory]
|
* @param {string} [opts.directory]
|
||||||
* @param {Array<{vault:string,path:string}>} [opts.documents]
|
* @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 mode = opts.mode || MODE.GENERAL;
|
||||||
const vault = opts.vault || null;
|
const vault = opts.vault || null;
|
||||||
const directory = opts.directory || '';
|
const directory = opts.directory || '';
|
||||||
const documents = Array.isArray(opts.documents) ? opts.documents : [];
|
const documents = Array.isArray(opts.documents) ? opts.documents : [];
|
||||||
|
|
||||||
const sameContext =
|
const sameContext =
|
||||||
|
preferredSessionId == null &&
|
||||||
this._isOpen &&
|
this._isOpen &&
|
||||||
this._mode === mode &&
|
this._mode === mode &&
|
||||||
this._vault === vault &&
|
this._vault === vault &&
|
||||||
@@ -204,7 +215,7 @@ class BooksLM {
|
|||||||
this._images = [];
|
this._images = [];
|
||||||
this._activeSkill = null;
|
this._activeSkill = null;
|
||||||
this._isLoading = true;
|
this._isLoading = true;
|
||||||
this._loadHistory();
|
await this._loadHistory(preferredSessionId);
|
||||||
|
|
||||||
if (!this._panel) {
|
if (!this._panel) {
|
||||||
this._panel = this._render();
|
this._panel = this._render();
|
||||||
@@ -243,6 +254,29 @@ class BooksLM {
|
|||||||
return this.openContext({ mode: MODE.DIRECTORY, vault, directory });
|
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
|
* Open the assistant using the current application context: the open
|
||||||
* document(s) if any, otherwise the General assistant.
|
* document(s) if any, otherwise the General assistant.
|
||||||
@@ -308,6 +342,7 @@ class BooksLM {
|
|||||||
this._sessions.unshift(session);
|
this._sessions.unshift(session);
|
||||||
this._currentSessionId = session.id;
|
this._currentSessionId = session.id;
|
||||||
this._messages = session.messages;
|
this._messages = session.messages;
|
||||||
|
this._dirtySessionIds.add(session.id);
|
||||||
this._persistSessions();
|
this._persistSessions();
|
||||||
this._renderMessages();
|
this._renderMessages();
|
||||||
this._showSuggestions();
|
this._showSuggestions();
|
||||||
@@ -884,11 +919,15 @@ class BooksLM {
|
|||||||
<div class="bookslm-mention-menu hidden" role="listbox" aria-label="${t('ai.add_context')}"></div>
|
<div class="bookslm-mention-menu hidden" role="listbox" aria-label="${t('ai.add_context')}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bookslm-input-area">
|
<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-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>
|
<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>
|
||||||
|
<div class="bookslm-ext-menu hidden"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Provider/model picker lives on its own toolbar row so the header
|
// Provider/model picker lives on its own toolbar row so the header
|
||||||
@@ -970,16 +1009,31 @@ class BooksLM {
|
|||||||
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
|
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
|
||||||
if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e));
|
if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e));
|
||||||
|
|
||||||
// Image attachment button (pick image files from disk).
|
// #97 — The "+" button opens the extensible options panel; the panel can
|
||||||
const attachBtn = panel.querySelector('.bookslm-btn-attach');
|
// attach images (existing picker), upload any file, or trigger commands.
|
||||||
|
const plusBtn = panel.querySelector('.bookslm-btn-plus');
|
||||||
const fileInput = panel.querySelector('.bookslm-file-input');
|
const fileInput = panel.querySelector('.bookslm-file-input');
|
||||||
if (attachBtn && fileInput) {
|
const filesInput = panel.querySelector('.bookslm-files-input');
|
||||||
attachBtn.addEventListener('click', () => fileInput.click());
|
if (plusBtn) plusBtn.addEventListener('click', () => this._toggleExtMenu());
|
||||||
|
if (fileInput) {
|
||||||
fileInput.addEventListener('change', () => {
|
fileInput.addEventListener('change', () => {
|
||||||
this._addImageFiles(fileInput.files);
|
this._addImageFiles(fileInput.files);
|
||||||
fileInput.value = '';
|
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');
|
const textarea = panel.querySelector('textarea');
|
||||||
textarea.addEventListener('input', () => {
|
textarea.addEventListener('input', () => {
|
||||||
@@ -1263,6 +1317,151 @@ class BooksLM {
|
|||||||
Array.from(files).forEach((file) => this._addImageFile(file));
|
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', () => {
|
||||||
|
if (!entry.disabled && entry.action) entry.action();
|
||||||
|
});
|
||||||
|
menu.appendChild(item);
|
||||||
|
}
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'bookslm-ext-footer';
|
||||||
|
footer.textContent = t('bookslm.ext_more_coming');
|
||||||
|
menu.appendChild(footer);
|
||||||
|
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #97 — Deep Research: enabled agent tools + a pre-filled directive. */
|
||||||
|
_startDeepResearch() {
|
||||||
|
this._closeExtMenu();
|
||||||
|
if (!this._agentMode) this._toggleAgentMode();
|
||||||
|
const textarea = this._panel && this._panel.querySelector('textarea');
|
||||||
|
if (textarea) {
|
||||||
|
const directive = textarea.value.trim()
|
||||||
|
? textarea.value.replace(/\s*$/, '')
|
||||||
|
: '';
|
||||||
|
textarea.value = (directive ? directive + '\n\n' : '') + t('bookslm.deep_research_prompt');
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
textarea.focus();
|
||||||
|
} else {
|
||||||
|
showToast(t('bookslm.deep_research_started'), 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_onPaste(e) {
|
_onPaste(e) {
|
||||||
const items = e.clipboardData && e.clipboardData.items;
|
const items = e.clipboardData && e.clipboardData.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
@@ -2978,10 +3177,12 @@ class BooksLM {
|
|||||||
|
|
||||||
// ── Session history ─────────────────────────────────────────────────
|
// ── Session history ─────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Each context (directory / documents / general) owns a list of sessions
|
// #95 — Conversations are persisted server-side
|
||||||
// persisted in localStorage. A session is `{id, title, createdAt,
|
// (`/api/ai/bookslm/history`), localStorage being kept as an offline cache.
|
||||||
// updatedAt, messages}`; the header history button lists them so a previous
|
// A session is `{id, title, mode, vault, directory, documents, context,
|
||||||
// conversation can be reopened or deleted.
|
// 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() {
|
_contextKey() {
|
||||||
const ctx = this._mode === MODE.DOCUMENTS
|
const ctx = this._mode === MODE.DOCUMENTS
|
||||||
@@ -2990,7 +3191,20 @@ class BooksLM {
|
|||||||
return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
|
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() {
|
_sessionsKey() {
|
||||||
|
return this._userScopedKey('bookslm-sessions-all');
|
||||||
|
}
|
||||||
|
|
||||||
|
_legacySessionsKey() {
|
||||||
return `bookslm-sessions-${this._contextKey()}`;
|
return `bookslm-sessions-${this._contextKey()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3004,13 +3218,57 @@ class BooksLM {
|
|||||||
|
|
||||||
_createSession(messages) {
|
_createSession(messages) {
|
||||||
const now = Date.now();
|
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() {
|
_currentSession() {
|
||||||
return this._sessions.find((s) => s.id === this._currentSessionId) || null;
|
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() {
|
_readSessions() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(this._sessionsKey());
|
const raw = localStorage.getItem(this._sessionsKey());
|
||||||
@@ -3018,78 +3276,215 @@ class BooksLM {
|
|||||||
const list = JSON.parse(raw);
|
const list = JSON.parse(raw);
|
||||||
if (!Array.isArray(list)) return [];
|
if (!Array.isArray(list)) return [];
|
||||||
return list
|
return list
|
||||||
.filter((s) => s && typeof s === 'object' && Array.isArray(s.messages))
|
.filter((s) => s && typeof s === 'object')
|
||||||
.map((s) => ({
|
.map((s) => this._normalizeSession(s));
|
||||||
id: s.id || this._newSessionId(),
|
|
||||||
title: s.title || '',
|
|
||||||
createdAt: s.createdAt || Date.now(),
|
|
||||||
updatedAt: s.updatedAt || s.createdAt || Date.now(),
|
|
||||||
messages: s.messages,
|
|
||||||
}));
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import the pre-sessions single conversation, if any. */
|
/** Import the pre-#95 per-context keys into the global store, once. */
|
||||||
_migrateLegacyHistory() {
|
_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 {
|
try {
|
||||||
const raw = localStorage.getItem(this._legacyHistoryKey());
|
const raw = localStorage.getItem(this._legacyHistoryKey());
|
||||||
if (!raw) return null;
|
if (raw) {
|
||||||
const messages = JSON.parse(raw);
|
const messages = JSON.parse(raw);
|
||||||
if (!Array.isArray(messages) || !messages.length) return null;
|
if (Array.isArray(messages) && messages.length) {
|
||||||
const session = this._createSession(messages);
|
const session = this._createSession(messages);
|
||||||
session.title = this._deriveTitle(messages);
|
session.title = this._deriveTitle(messages);
|
||||||
return session;
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadHistory() {
|
_persistableMessages(messages) {
|
||||||
this._sessions = this._readSessions();
|
return (messages || []).map(({ confirmation, payload, ...rest }) => rest);
|
||||||
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 : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_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 {
|
try {
|
||||||
localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions));
|
localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions));
|
||||||
} catch { /* quota / private mode */ }
|
} catch { /* quota / private mode */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip transient (large) fields before persisting a conversation. */
|
/** Local cache + schedule the server sync (#95). */
|
||||||
_persistableMessages() {
|
_persistSessions() {
|
||||||
return this._messages.map(({ confirmation, payload, ...rest }) => rest);
|
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() {
|
_saveHistory() {
|
||||||
const persisted = this._persistableMessages();
|
const persisted = this._persistableMessages(this._messages);
|
||||||
const session = this._currentSession();
|
const session = this._currentSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
session.messages = persisted;
|
session.messages = persisted;
|
||||||
session.updatedAt = Date.now();
|
session.updatedAt = Date.now();
|
||||||
if (!session.title) session.title = this._deriveTitle(this._messages);
|
if (!session.title) session.title = this._deriveTitle(this._messages);
|
||||||
|
this._dirtySessionIds.add(session.id);
|
||||||
|
this._positionSession(session);
|
||||||
} else {
|
} else {
|
||||||
const created = this._createSession(persisted);
|
const created = this._createSession(persisted);
|
||||||
created.title = this._deriveTitle(this._messages);
|
created.title = this._deriveTitle(this._messages);
|
||||||
this._sessions.unshift(created);
|
this._positionSession(created);
|
||||||
this._currentSessionId = created.id;
|
this._currentSessionId = created.id;
|
||||||
|
this._dirtySessionIds.add(created.id);
|
||||||
}
|
}
|
||||||
this._persistSessions();
|
this._persistSessions();
|
||||||
}
|
}
|
||||||
@@ -3101,31 +3496,51 @@ class BooksLM {
|
|||||||
return line.length > 60 ? line.slice(0, 57) + '…' : line;
|
return line.length > 60 ? line.slice(0, 57) + '…' : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadSession(id) {
|
/** Select a session, hydrating it from the server when only a summary. */
|
||||||
const session = this._sessions.find((s) => s.id === id);
|
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;
|
if (!session) return;
|
||||||
this._currentSessionId = id;
|
this._currentSessionId = session.id;
|
||||||
this._messages = Array.isArray(session.messages) ? session.messages : [];
|
this._messages = Array.isArray(session.messages) ? session.messages : [];
|
||||||
this._closeHistoryMenu();
|
this._closeHistoryMenu();
|
||||||
if (this._panel) {
|
if (this._panel) {
|
||||||
|
this._updateHeader();
|
||||||
this._renderMessages({ anchor: true });
|
this._renderMessages({ anchor: true });
|
||||||
this._showSuggestions();
|
this._showSuggestions();
|
||||||
this._renderHistoryMenu();
|
this._renderHistoryMenu();
|
||||||
}
|
}
|
||||||
|
this._persistLocalCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadSession(id) {
|
||||||
|
await this._selectSessionById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
_deleteSession(id) {
|
_deleteSession(id) {
|
||||||
this._sessions = this._sessions.filter((s) => s.id !== id);
|
this._sessions = this._sessions.filter((s) => s.id !== id);
|
||||||
|
this._dirtySessionIds.delete(id);
|
||||||
|
this._deletedIds.add(id);
|
||||||
|
let session;
|
||||||
if (!this._sessions.length) {
|
if (!this._sessions.length) {
|
||||||
const session = this._createSession([]);
|
session = this._createSession([]);
|
||||||
this._sessions.push(session);
|
this._sessions.push(session);
|
||||||
this._currentSessionId = session.id;
|
this._dirtySessionIds.add(session.id);
|
||||||
this._messages = session.messages;
|
|
||||||
} else if (id === this._currentSessionId) {
|
} else if (id === this._currentSessionId) {
|
||||||
const next = this._sessions[0];
|
session = this._sessions[0];
|
||||||
this._currentSessionId = next.id;
|
} else {
|
||||||
this._messages = next.messages || [];
|
session = this._currentSession() || this._sessions[0];
|
||||||
}
|
}
|
||||||
|
this._currentSessionId = session.id;
|
||||||
|
this._messages = session.messages || [];
|
||||||
this._persistSessions();
|
this._persistSessions();
|
||||||
if (this._panel) {
|
if (this._panel) {
|
||||||
this._renderMessages();
|
this._renderMessages();
|
||||||
@@ -3174,11 +3589,13 @@ class BooksLM {
|
|||||||
const title = document.createElement('span');
|
const title = document.createElement('span');
|
||||||
title.className = 'bookslm-history-title';
|
title.className = 'bookslm-history-title';
|
||||||
title.textContent = session.title || t('bookslm.untitled');
|
title.textContent = session.title || t('bookslm.untitled');
|
||||||
const date = document.createElement('span');
|
const meta = document.createElement('span');
|
||||||
date.className = 'bookslm-history-date';
|
meta.className = 'bookslm-history-meta';
|
||||||
date.textContent = this._formatDate(session.updatedAt);
|
const label = this._historyContextLabel(session);
|
||||||
|
const date = this._formatDate(session.updatedAt);
|
||||||
|
meta.textContent = label ? `${label} · ${date}` : date;
|
||||||
open.appendChild(title);
|
open.appendChild(title);
|
||||||
open.appendChild(date);
|
open.appendChild(meta);
|
||||||
open.addEventListener('click', () => this._loadSession(session.id));
|
open.addEventListener('click', () => this._loadSession(session.id));
|
||||||
|
|
||||||
const del = document.createElement('button');
|
const del = document.createElement('button');
|
||||||
@@ -3208,6 +3625,19 @@ class BooksLM {
|
|||||||
return '';
|
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();
|
const booksLM = new BooksLM();
|
||||||
|
|||||||
+76
-1
@@ -147,6 +147,73 @@ function initRecentTab() {
|
|||||||
_recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000);
|
_recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI history sidebar tab (#96)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _formatAIDate(ts) {
|
||||||
|
if (!ts) return "";
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAISessionList() {
|
||||||
|
const listEl = document.getElementById("ai-history-list");
|
||||||
|
const emptyEl = document.getElementById("ai-history-empty");
|
||||||
|
if (!listEl) return;
|
||||||
|
let sessions = [];
|
||||||
|
try {
|
||||||
|
const data = await api("/api/ai/bookslm/history");
|
||||||
|
sessions = data.sessions || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load AI history:", err);
|
||||||
|
}
|
||||||
|
renderAIHistoryList(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAIHistoryList(sessions) {
|
||||||
|
const listEl = document.getElementById("ai-history-list");
|
||||||
|
const emptyEl = document.getElementById("ai-history-empty");
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
|
||||||
|
if (!sessions || sessions.length === 0) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.classList.remove("hidden");
|
||||||
|
safeCreateIcons();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emptyEl) emptyEl.classList.add("hidden");
|
||||||
|
|
||||||
|
sessions.forEach((s) => {
|
||||||
|
const item = el("div", { class: "recent-item", "data-session-id": s.id });
|
||||||
|
const header = el("div", { class: "recent-item-header" });
|
||||||
|
const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(_formatAIDate(s.updatedAt))]);
|
||||||
|
header.appendChild(timeSpan);
|
||||||
|
header.appendChild(el("span", { class: "recent-vault-badge" }, [document.createTextNode(t("bookslm.mode_" + (s.mode || "general"))) ]));
|
||||||
|
item.appendChild(header);
|
||||||
|
|
||||||
|
const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(s.title || t("bookslm.untitled"))]);
|
||||||
|
item.appendChild(titleEl);
|
||||||
|
|
||||||
|
if (s.preview) {
|
||||||
|
item.appendChild(el("div", { class: "recent-item-preview" }, [document.createTextNode(s.preview)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
closeMobileSidebar();
|
||||||
|
import("./bookslm.js").then((m) => m.default.openWithSession(s.id)).catch((e) => console.error(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
safeCreateIcons();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar tabs
|
// Sidebar tabs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -154,6 +221,10 @@ function initSidebarTabs() {
|
|||||||
document.querySelectorAll(".sidebar-tab").forEach((tab) => {
|
document.querySelectorAll(".sidebar-tab").forEach((tab) => {
|
||||||
tab.addEventListener("click", () => switchSidebarTab(tab.dataset.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) {
|
function switchSidebarTab(tab) {
|
||||||
@@ -169,7 +240,7 @@ function switchSidebarTab(tab) {
|
|||||||
});
|
});
|
||||||
const filterInput = document.getElementById("sidebar-filter-input");
|
const filterInput = document.getElementById("sidebar-filter-input");
|
||||||
if (filterInput) {
|
if (filterInput) {
|
||||||
const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" };
|
const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "", ai: "" };
|
||||||
filterInput.placeholder = placeholders[tab] || "";
|
filterInput.placeholder = placeholders[tab] || "";
|
||||||
}
|
}
|
||||||
const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : "";
|
const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : "";
|
||||||
@@ -183,6 +254,10 @@ function switchSidebarTab(tab) {
|
|||||||
const vaultFilter = document.getElementById("recent-vault-filter");
|
const vaultFilter = document.getElementById("recent-vault-filter");
|
||||||
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||||||
}
|
}
|
||||||
|
// #96 — Auto-load the AI conversation history when entering its tab.
|
||||||
|
if (tab === "ai") {
|
||||||
|
loadAISessionList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initHelpModal() {
|
function initHelpModal() {
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ export function isInlineEditorActive() {
|
|||||||
return !!(container && container.classList.contains(INLINE_CONTAINER_CLASS));
|
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.
|
* Move the editor container into `area`, replacing the rendered document.
|
||||||
* @returns {boolean} true when the container was mounted inline.
|
* @returns {boolean} true when the container was mounted inline.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { api } from './auth.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;
|
const PANE_MAX = 4;
|
||||||
|
|
||||||
@@ -154,6 +154,9 @@ function createPaneTabManager(paneId) {
|
|||||||
|
|
||||||
async activate(tabId) {
|
async activate(tabId) {
|
||||||
if (this._activeTabId === tabId && this._tabs.length > 0) return;
|
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])
|
if (this._activeTabId && this._tabCache[this._activeTabId])
|
||||||
this._saveCurrentTabState();
|
this._saveCurrentTabState();
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -6,7 +6,7 @@ import { api, AuthManager } from './auth.js';
|
|||||||
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||||
import { loadRecentFiles, initAboutModal } from './config.js';
|
import { loadRecentFiles, initAboutModal } from './config.js';
|
||||||
import { openFile } from './viewer.js';
|
import { openFile } from './viewer.js';
|
||||||
import { closeEditor } from './utils.js';
|
import { closeEditor, reloadExternalWrite } from './utils.js';
|
||||||
import { initOffline, syncFileIndexFromServer } from './offline.js';
|
import { initOffline, syncFileIndexFromServer } from './offline.js';
|
||||||
import { initThemes } from './themes.js';
|
import { initThemes } from './themes.js';
|
||||||
|
|
||||||
@@ -166,7 +166,11 @@ export const IndexUpdateManager = (() => {
|
|||||||
if (state.currentVault && state.currentPath) {
|
if (state.currentVault && state.currentPath) {
|
||||||
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
|
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
|
||||||
if (changed) {
|
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) {
|
} catch (err) {
|
||||||
|
|||||||
+6
-2
@@ -2,7 +2,7 @@
|
|||||||
import { api, AuthManager, initLoginForm } from './auth.js';
|
import { api, AuthManager, initLoginForm } from './auth.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { openFile, showWelcome, renderFile, el } from './viewer.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 { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||||
import { GraphViewManager } from './graph.js';
|
import { GraphViewManager } from './graph.js';
|
||||||
import { DashboardConflictsWidget } from './dashboard.js';
|
import { DashboardConflictsWidget } from './dashboard.js';
|
||||||
@@ -2014,7 +2014,11 @@ export const TabManager = {
|
|||||||
/** Activate a specific tab */
|
/** Activate a specific tab */
|
||||||
async activate(tabId) {
|
async activate(tabId) {
|
||||||
if (this._activeTabId === tabId && this._tabs.length > 0) return;
|
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
|
// Save current tab state
|
||||||
if (this._activeTabId && this._tabCache[this._activeTabId]) {
|
if (this._activeTabId && this._tabCache[this._activeTabId]) {
|
||||||
this._saveCurrentTabState();
|
this._saveCurrentTabState();
|
||||||
|
|||||||
+14
-13
@@ -6,9 +6,11 @@ import { createAIToolbar } from './ai.js';
|
|||||||
import { startCollab, stopCollab } from './collab.js';
|
import { startCollab, stopCollab } from './collab.js';
|
||||||
import {
|
import {
|
||||||
getInlineHostArea,
|
getInlineHostArea,
|
||||||
|
getEditorContainer,
|
||||||
mountEditorInline,
|
mountEditorInline,
|
||||||
unmountEditorInline,
|
unmountEditorInline,
|
||||||
isInlineEditorActive,
|
isInlineEditorActive,
|
||||||
|
queryEditor,
|
||||||
} from './editor-inline.js';
|
} from './editor-inline.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -372,9 +374,9 @@ async function openEditor(vaultName, filePath) {
|
|||||||
const bodyEl = document.getElementById("editor-body");
|
const bodyEl = document.getElementById("editor-body");
|
||||||
|
|
||||||
// Restore header/footer (may be hidden by Forge)
|
// Restore header/footer (may be hidden by Forge)
|
||||||
var headerEl = modal.querySelector(".editor-header");
|
var headerEl = queryEditor(".editor-header");
|
||||||
var footerEl = modal.querySelector(".editor-footer");
|
var footerEl = queryEditor(".editor-footer");
|
||||||
var container = modal.querySelector(".editor-container");
|
var container = getEditorContainer();
|
||||||
if (headerEl) headerEl.style.display = "";
|
if (headerEl) headerEl.style.display = "";
|
||||||
if (footerEl) footerEl.style.display = "";
|
if (footerEl) footerEl.style.display = "";
|
||||||
if (container) container.style.maxHeight = "";
|
if (container) container.style.maxHeight = "";
|
||||||
@@ -580,14 +582,6 @@ function closeEditor() {
|
|||||||
const modal = document.getElementById("editor-modal");
|
const modal = document.getElementById("editor-modal");
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
modal.classList.remove("active");
|
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();
|
stopCollab();
|
||||||
if (state.editorView) {
|
if (state.editorView) {
|
||||||
state.editorView.destroy();
|
state.editorView.destroy();
|
||||||
@@ -598,9 +592,16 @@ function closeEditor() {
|
|||||||
// Remove forge iframe if present
|
// Remove forge iframe if present
|
||||||
var forgeFrame = document.getElementById("forge-iframe");
|
var forgeFrame = document.getElementById("forge-iframe");
|
||||||
if (forgeFrame) forgeFrame.remove();
|
if (forgeFrame) forgeFrame.remove();
|
||||||
// #93 — give the editor container back to the modal; an inline session also
|
// #93 — hand the editor container back to the modal BEFORE restoring the
|
||||||
// releases the content area, whose read view must be re-rendered.
|
// 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();
|
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);
|
_invalidateActiveTabCache(vault, path);
|
||||||
state.editorVault = null;
|
state.editorVault = null;
|
||||||
state.editorPath = null;
|
state.editorPath = null;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { api, AuthManager } from './auth.js';
|
import { api, AuthManager } from './auth.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.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 { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
|
||||||
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
|
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
|
||||||
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
||||||
@@ -768,8 +768,9 @@ export function renderFile(data) {
|
|||||||
state.editorVault = data.vault;
|
state.editorVault = data.vault;
|
||||||
state.editorPath = data.path;
|
state.editorPath = data.path;
|
||||||
}
|
}
|
||||||
// Update header: show "Forge" brand + filename
|
// Update header: show "Forge" brand + filename (the header travels with the
|
||||||
var brand = modal.querySelector(".editor-brand");
|
// container in inline mode — query it wherever it lives, #93)
|
||||||
|
var brand = queryEditor(".editor-brand");
|
||||||
var titleInp = document.getElementById("editor-title-input");
|
var titleInp = document.getElementById("editor-title-input");
|
||||||
var fileInp = document.getElementById("editor-filename-input");
|
var fileInp = document.getElementById("editor-filename-input");
|
||||||
if (brand) brand.textContent = t("viewer.forge_brand");
|
if (brand) brand.textContent = t("viewer.forge_brand");
|
||||||
|
|||||||
@@ -1829,6 +1829,28 @@
|
|||||||
"bookslm.untitled": "Untitled conversation",
|
"bookslm.untitled": "Untitled conversation",
|
||||||
"bookslm.no_vault": "No active vault to open this link",
|
"bookslm.no_vault": "No active vault to open this link",
|
||||||
"bookslm.copied_name": "Name copied: {name}",
|
"bookslm.copied_name": "Name copied: {name}",
|
||||||
|
"bookslm.add_options": "Add options",
|
||||||
|
"bookslm.ext_files": "Upload files",
|
||||||
|
"bookslm.ext_files_hint": "Add files to the conversation context",
|
||||||
|
"bookslm.ext_image_hint": "Attach an image for vision-capable models",
|
||||||
|
"bookslm.ext_contexts": "Add contexts",
|
||||||
|
"bookslm.ext_contexts_hint": "Attach files or directories with the @ command",
|
||||||
|
"bookslm.ext_skills": "Add skills",
|
||||||
|
"bookslm.ext_skills_hint": "Select an active skill",
|
||||||
|
"bookslm.ext_deep_research": "Deep Research",
|
||||||
|
"bookslm.ext_deep_research_hint": "Multi-step in-depth research",
|
||||||
|
"bookslm.ext_web": "Internet search",
|
||||||
|
"bookslm.ext_web_hint": "Coming soon",
|
||||||
|
"bookslm.ext_canva": "Canva",
|
||||||
|
"bookslm.ext_canva_hint": "Coming soon",
|
||||||
|
"bookslm.coming_soon": "Feature coming soon",
|
||||||
|
"bookslm.soon": "Soon",
|
||||||
|
"bookslm.ext_more_coming": "More options coming…",
|
||||||
|
"bookslm.deep_research_prompt": "Perform an in-depth analysis of the request by breaking it into steps, searching for relevant information in the vault, cross-referencing them, and producing a structured summary with sources.",
|
||||||
|
"bookslm.deep_research_started": "Deep research started — enable Agent mode to use the tools.",
|
||||||
|
"bookslm.mode_general": "General",
|
||||||
|
"bookslm.mode_directory": "Directory",
|
||||||
|
"bookslm.mode_documents": "Documents",
|
||||||
"palette.bookslm_open": "BooksLM: Open for current directory",
|
"palette.bookslm_open": "BooksLM: Open for current directory",
|
||||||
"palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)",
|
"palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)",
|
||||||
"palette.bookslm_new": "BooksLM: New conversation",
|
"palette.bookslm_new": "BooksLM: New conversation",
|
||||||
|
|||||||
@@ -1829,6 +1829,28 @@
|
|||||||
"bookslm.untitled": "Conversation sans titre",
|
"bookslm.untitled": "Conversation sans titre",
|
||||||
"bookslm.no_vault": "Aucun vault actif pour ouvrir ce lien",
|
"bookslm.no_vault": "Aucun vault actif pour ouvrir ce lien",
|
||||||
"bookslm.copied_name": "Nom copié : {name}",
|
"bookslm.copied_name": "Nom copié : {name}",
|
||||||
|
"bookslm.add_options": "Options d'ajout",
|
||||||
|
"bookslm.ext_files": "Téléverser des fichiers",
|
||||||
|
"bookslm.ext_files_hint": "Ajouter des fichiers au contexte de la conversation",
|
||||||
|
"bookslm.ext_image_hint": "Joindre une image pour les modèles à vision",
|
||||||
|
"bookslm.ext_contexts": "Ajouter des contextes",
|
||||||
|
"bookslm.ext_contexts_hint": "Joindre des fichiers ou répertoires via la commande @",
|
||||||
|
"bookslm.ext_skills": "Ajouter des skills",
|
||||||
|
"bookslm.ext_skills_hint": "Sélectionner un skill actif",
|
||||||
|
"bookslm.ext_deep_research": "Deep Research",
|
||||||
|
"bookslm.ext_deep_research_hint": "Recherche approfondie à étapes multiples",
|
||||||
|
"bookslm.ext_web": "Recherche sur Internet",
|
||||||
|
"bookslm.ext_web_hint": "Bientôt disponible",
|
||||||
|
"bookslm.ext_canva": "Canva",
|
||||||
|
"bookslm.ext_canva_hint": "Bientôt disponible",
|
||||||
|
"bookslm.coming_soon": "Fonctionnalité à venir",
|
||||||
|
"bookslm.soon": "Bientôt",
|
||||||
|
"bookslm.ext_more_coming": "D'autres options à venir…",
|
||||||
|
"bookslm.deep_research_prompt": "Réalise une analyse approfondie de la demande en décomposant les étapes, en cherchant les informations pertinentes dans le vault, en les croisant, et en produisant une synthèse structurée avec sources.",
|
||||||
|
"bookslm.deep_research_started": "Deep Research lancé — activez le mode Agent pour utiliser les outils.",
|
||||||
|
"bookslm.mode_general": "Général",
|
||||||
|
"bookslm.mode_directory": "Répertoire",
|
||||||
|
"bookslm.mode_documents": "Documents",
|
||||||
"palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant",
|
"palette.bookslm_open": "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_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)",
|
||||||
"palette.bookslm_new": "BooksLM: Nouvelle conversation",
|
"palette.bookslm_new": "BooksLM: Nouvelle conversation",
|
||||||
|
|||||||
+26
-4
@@ -9526,7 +9526,9 @@ body.popup-mode .content-area {
|
|||||||
.bookslm-input-area textarea:focus { border-color: var(--accent); outline: none; }
|
.bookslm-input-area textarea:focus { border-color: var(--accent); outline: none; }
|
||||||
.bookslm-input-area button { padding: 8px 16px; border-radius: 8px; border: none;
|
.bookslm-input-area button { padding: 8px 16px; border-radius: 8px; border: none;
|
||||||
background: var(--accent); color: #fff; cursor: pointer; font-size: 14px; }
|
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:hover { opacity: 0.9; }
|
||||||
.bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
|
.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);
|
.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; }
|
color: var(--text-primary); border-radius: 8px; }
|
||||||
.bookslm-history-title { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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-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);
|
.bookslm-history-delete { background: none; border: none; cursor: pointer; color: var(--text-secondary);
|
||||||
padding: 0 8px; border-radius: 6px; display: flex; align-items: center; }
|
padding: 0 8px; border-radius: 6px; display: flex; align-items: center; }
|
||||||
.bookslm-history-delete:hover { color: #f87171; background: rgba(248,113,113,0.12); }
|
.bookslm-history-delete:hover { color: #f87171; background: rgba(248,113,113,0.12); }
|
||||||
@@ -9692,11 +9696,29 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
|||||||
white-space: nowrap; }
|
white-space: nowrap; }
|
||||||
.bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
|
.bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
|
||||||
/* Image attach button. */
|
/* Image attach button. */
|
||||||
.bookslm-btn-attach { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||||
background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer;
|
background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer;
|
||||||
border-radius: 8px; padding: 7px; }
|
border-radius: 50%; width: 32px; height: 32px; padding: 0; }
|
||||||
.bookslm-btn-attach:hover { color: var(--accent); border-color: var(--accent); }
|
.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
.bookslm-file-input { display: none; }
|
.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. */
|
/* Create-skill modal. */
|
||||||
.bookslm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 2000;
|
.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; }
|
display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.3.2",
|
"version": "2.4.0",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ def resolve_bump(args: argparse.Namespace, root: Path) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
args = build_parser().parse_args(argv)
|
args = build_parser().parse_args(argv)
|
||||||
root = REPO_ROOT
|
root = REPO_ROOT
|
||||||
|
|
||||||
|
|||||||
@@ -916,16 +916,25 @@ async function main() {
|
|||||||
b._panel.remove();
|
b._panel.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 13. Session management ──
|
// ── 13. Session management (#95/#96 — persistent backend history) ──
|
||||||
await test("sessions persist and can be reopened and deleted", () => {
|
await test("sessions persist and can be reopened and deleted", async () => {
|
||||||
localStorage.clear();
|
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();
|
const b = new BooksLM();
|
||||||
b._panel = document.createElement("div");
|
b._panel = document.createElement("div");
|
||||||
b._mode = MODE.DIRECTORY;
|
b._mode = MODE.DIRECTORY;
|
||||||
b._vault = "VaultSessions";
|
b._vault = "VaultSessions";
|
||||||
b._directory = "notes";
|
b._directory = "notes";
|
||||||
b._loadHistory();
|
await b._loadHistory();
|
||||||
assert.equal(b._sessions.length, 1, "one session created on first load");
|
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: "user", content: "Première question" });
|
||||||
b._messages.push({ role: "assistant", content: "Réponse" });
|
b._messages.push({ role: "assistant", content: "Réponse" });
|
||||||
@@ -938,7 +947,7 @@ async function main() {
|
|||||||
b._messages.push({ role: "user", content: "Deuxième" });
|
b._messages.push({ role: "user", content: "Deuxième" });
|
||||||
b._saveHistory();
|
b._saveHistory();
|
||||||
|
|
||||||
b._loadSession(firstId);
|
await b._loadSession(firstId);
|
||||||
assert.equal(b._messages.length, 2, "previous session messages restored");
|
assert.equal(b._messages.length, 2, "previous session messages restored");
|
||||||
assert.equal(b._messages[0].content, "Première question");
|
assert.equal(b._messages[0].content, "Première question");
|
||||||
const first = b._sessions.find((s) => s.id === firstId);
|
const first = b._sessions.find((s) => s.id === firstId);
|
||||||
@@ -979,16 +988,26 @@ async function main() {
|
|||||||
b.close();
|
b.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test("legacy single-conversation history is migrated", () => {
|
await test("legacy single-conversation history is migrated", async () => {
|
||||||
localStorage.clear();
|
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();
|
const b = new BooksLM();
|
||||||
b._mode = MODE.GENERAL;
|
b._mode = MODE.GENERAL;
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
b._legacyHistoryKey(),
|
b._legacyHistoryKey(),
|
||||||
JSON.stringify([{ role: "user", content: "Ancienne conversation" }])
|
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.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");
|
assert.equal(b._messages[0].content, "Ancienne conversation");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const {
|
|||||||
isInlineEditorActive,
|
isInlineEditorActive,
|
||||||
mountEditorInline,
|
mountEditorInline,
|
||||||
unmountEditorInline,
|
unmountEditorInline,
|
||||||
|
queryEditor,
|
||||||
} = mod;
|
} = mod;
|
||||||
|
|
||||||
let testCount = 0;
|
let testCount = 0;
|
||||||
@@ -161,6 +162,17 @@ test("unmount without an inline session is a no-op", () => {
|
|||||||
assert.equal(container().parentElement, modal());
|
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", () => {
|
test("host area follows the active pane in split view", () => {
|
||||||
reset();
|
reset();
|
||||||
const paneArea = document.createElement("div");
|
const paneArea = document.createElement("div");
|
||||||
@@ -193,12 +205,37 @@ const forgeSrc = read("frontend", "editor-poc.html");
|
|||||||
|
|
||||||
console.log("Inline edition (#93) — wiring");
|
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", () => {
|
test("index.html gives the editor container a stable id", () => {
|
||||||
assert.match(indexSrc, /<div class="editor-container" id="editor-container">/);
|
assert.match(indexSrc, /<div class="editor-container" id="editor-container">/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("viewer.js mounts the editor inline when rendering is taken over", () => {
|
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\(\);/);
|
const guard = viewerSrc.match(/export function renderFile\(data\) \{([\s\S]*?)\n const area = getContentArea\(\);/);
|
||||||
assert.ok(guard, "renderFile head not found");
|
assert.ok(guard, "renderFile head not found");
|
||||||
assert.match(guard[1], /if \(isInlineEditorActive\(\)\) detachInlineEditor\(\);/);
|
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", () => {
|
test("viewer.js Forge button mounts inline and sizes the iframe accordingly", () => {
|
||||||
assert.match(viewerSrc, /var inline = activateInlineEditor\(data\.vault, data\.path\);/);
|
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(
|
assert.match(
|
||||||
viewerSrc,
|
viewerSrc,
|
||||||
/iframe\.style\.cssText = "width:100%;height:" \+ \(inline \? "100%" : "82vh"\) \+ ";border:none;display:block;";/,
|
/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", () => {
|
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 \}/);
|
const handler = syncSrc.match(/if \(e\.data\.type === 'forge-close'\) \{([\s\S]*?)\n \}/);
|
||||||
assert.ok(handler, "forge-close handler not found");
|
assert.ok(handler, "forge-close handler not found");
|
||||||
assert.match(handler[1], /closeEditor\(\);/);
|
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", () => {
|
test("bookslm.js reports the edited document and the assistant writes", () => {
|
||||||
assert.match(bookslmSrc, /const editing = this\._editingDocument\(\);/);
|
assert.match(bookslmSrc, /const editing = this\._editingDocument\(\);/);
|
||||||
assert.match(bookslmSrc, /if \(editing\) ctx\.editing = editing;/);
|
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], /height: auto;/, "must win over the mobile 100vh rule");
|
||||||
assert.match(fill[1], /max-width: none;/);
|
assert.match(fill[1], /max-width: none;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── BUG-045: one scroll surface only in the CodeMirror editor ──
|
// ── BUG-045: one scroll surface only in the CodeMirror editor ──
|
||||||
test("style.css: #editor-body scrolls through the CodeMirror scroller only", () => {
|
test("style.css: #editor-body scrolls through the CodeMirror scroller only", () => {
|
||||||
const body = cssSrc.match(/\.editor-body\.editor-body-cm \{([^}]*)\}/);
|
const body = cssSrc.match(/\.editor-body\.editor-body-cm \{([^}]*)\}/);
|
||||||
|
|||||||
@@ -1131,3 +1131,243 @@ class TestToolEventSources:
|
|||||||
assert data["sources"] == [{"title": "T", "url": "https://exemple.fr/t"}]
|
assert data["sources"] == [{"title": "T", "url": "https://exemple.fr/t"}]
|
||||||
# Non-web tools keep the key, empty, so the UI never guesses.
|
# 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"] == []
|
assert _json.loads(_tool_event_sse(self._record("read_file", {})).split("data: ", 1)[1])["sources"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestAIGatewayHistoryStore:
|
||||||
|
"""Unit tests for backend.ai_history persistence helpers."""
|
||||||
|
|
||||||
|
def test_upsert_roundtrip_summary(self, tmp_path, monkeypatch):
|
||||||
|
from backend import ai_history
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
|
||||||
|
session = {
|
||||||
|
"id": "s-abc",
|
||||||
|
"title": "Titre",
|
||||||
|
"mode": "directory",
|
||||||
|
"vault": "V",
|
||||||
|
"directory": "dossier",
|
||||||
|
"documents": [],
|
||||||
|
"context": "directory-V_dossier",
|
||||||
|
"createdAt": 10,
|
||||||
|
"updatedAt": 20,
|
||||||
|
"messages": [{"role": "user", "content": "Coucou"}, {"role": "assistant", "content": "Hey"}],
|
||||||
|
}
|
||||||
|
assert ai_history.upsert_session("alice", session) is not None
|
||||||
|
assert ai_history.upsert_session("bob", session) is not None # isolated per user store
|
||||||
|
|
||||||
|
summaries = ai_history.list_sessions("alice")
|
||||||
|
assert len(summaries) == 1
|
||||||
|
assert summaries[0]["message_count"] == 2
|
||||||
|
assert summaries[0]["preview"] == "Hey"
|
||||||
|
assert "messages" not in summaries[0]
|
||||||
|
|
||||||
|
assert len(ai_history.list_sessions("bob")) == 1
|
||||||
|
assert ai_history.list_sessions("carol") == []
|
||||||
|
|
||||||
|
full = ai_history.get_session("alice", "s-abc")
|
||||||
|
assert full["messages"][0]["content"] == "Coucou"
|
||||||
|
assert ai_history.get_session("alice", "s-missing") is None
|
||||||
|
|
||||||
|
assert ai_history.delete_session("alice", "s-abc") is True
|
||||||
|
assert ai_history.delete_session("alice", "s-abc") is False
|
||||||
|
assert ai_history.get_session("alice", "s-abc") is None
|
||||||
|
assert len(ai_history.list_sessions("bob")) == 1 # bob unaffected
|
||||||
|
|
||||||
|
def test_update_replaces_and_sorts(self, tmp_path, monkeypatch):
|
||||||
|
from backend import ai_history
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
|
||||||
|
ai_history.upsert_session("u", {"id": "s-1", "title": "Première", "updatedAt": 1, "messages": []})
|
||||||
|
ai_history.upsert_session("u", {"id": "s-2", "title": "Seconde", "updatedAt": 2, "messages": []})
|
||||||
|
ai_history.upsert_session("u", {"id": "s-1", "title": "Première MAJ", "updatedAt": 3, "messages": []})
|
||||||
|
|
||||||
|
titles = [s["title"] for s in ai_history.list_sessions("u")]
|
||||||
|
assert titles == ["Première MAJ", "Seconde"]
|
||||||
|
assert len(ai_history.list_sessions("u")) == 2
|
||||||
|
|
||||||
|
def test_max_sessions_cap(self, tmp_path, monkeypatch):
|
||||||
|
from backend import ai_history
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
|
||||||
|
monkeypatch.setattr(ai_history, "MAX_SESSIONS", 3)
|
||||||
|
for i in range(5):
|
||||||
|
ai_history.upsert_session("u", {"id": f"s-{i}", "title": f"S{i}", "updatedAt": i, "messages": []})
|
||||||
|
|
||||||
|
ids = [s["id"] for s in ai_history.list_sessions("u")]
|
||||||
|
assert len(ids) == 3
|
||||||
|
assert ids == ["s-4", "s-3", "s-2"] # most recent kept
|
||||||
|
|
||||||
|
def test_invalid_inputs(self, tmp_path, monkeypatch):
|
||||||
|
from backend import ai_history
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
|
||||||
|
assert ai_history.upsert_session("", {}) is None
|
||||||
|
assert ai_history.upsert_session("u", {"id": ""}) is None
|
||||||
|
assert ai_history.get_session("", "s-x") is None
|
||||||
|
assert ai_history.delete_session("", "s-x") is False
|
||||||
|
assert list_sessions_for_missing_user_is_empty(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def list_sessions_for_missing_user_is_empty(tmp_path):
|
||||||
|
# Local wrapper keeps the pytest node independent of the fixture chdir.
|
||||||
|
from backend import ai_history
|
||||||
|
|
||||||
|
old = ai_history.AI_HISTORY_DIR
|
||||||
|
ai_history.AI_HISTORY_DIR = tmp_path / "empty"
|
||||||
|
try:
|
||||||
|
return ai_history.list_sessions("ghost") == []
|
||||||
|
finally:
|
||||||
|
ai_history.AI_HISTORY_DIR = old
|
||||||
|
|
||||||
|
def _session_payload(self, session_id="s-test1", title="Ma conversation"):
|
||||||
|
return {
|
||||||
|
"id": session_id,
|
||||||
|
"title": title,
|
||||||
|
"mode": "general",
|
||||||
|
"vault": None,
|
||||||
|
"directory": "",
|
||||||
|
"documents": [],
|
||||||
|
"context": "general",
|
||||||
|
"createdAt": 1700000000000,
|
||||||
|
"updatedAt": 1700000001000,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Bonjour"},
|
||||||
|
{"role": "assistant", "content": "Salut !"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_history_requires_auth(self, bookslm_client):
|
||||||
|
"""The history endpoints require authentication."""
|
||||||
|
resp = bookslm_client.get("/api/ai/bookslm/history")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
resp = bookslm_client.put(
|
||||||
|
"/api/ai/bookslm/history/s-x",
|
||||||
|
json=self._session_payload(),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_upsert_then_list_then_get(self, bookslm_client):
|
||||||
|
"""PUT /history/{id} persists, GET /history lists a summary and GET /history/{id} returns the conversation."""
|
||||||
|
token, _ = _login_bookslm(bookslm_client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = bookslm_client.put(
|
||||||
|
"/api/ai/bookslm/history/s-test1",
|
||||||
|
json=self._session_payload(),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["id"] == "s-test1"
|
||||||
|
|
||||||
|
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
|
||||||
|
assert len(listing["sessions"]) == 1
|
||||||
|
summary = listing["sessions"][0]
|
||||||
|
assert summary["id"] == "s-test1"
|
||||||
|
assert summary["title"] == "Ma conversation"
|
||||||
|
assert summary["message_count"] == 2
|
||||||
|
assert "messages" not in summary
|
||||||
|
|
||||||
|
full = bookslm_client.get("/api/ai/bookslm/history/s-test1", headers=headers).json()
|
||||||
|
assert full["messages"][0]["content"] == "Bonjour"
|
||||||
|
assert full["context"] == "general"
|
||||||
|
|
||||||
|
def test_update_replaces_session(self, bookslm_client):
|
||||||
|
"""PUT with the same id replaces the stored conversation."""
|
||||||
|
token, _ = _login_bookslm(bookslm_client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
bookslm_client.put(
|
||||||
|
"/api/ai/bookslm/history/s-upd",
|
||||||
|
json=self._session_payload("s-upd", "Avant"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
payload = self._session_payload("s-upd", "Après")
|
||||||
|
resp = bookslm_client.put(
|
||||||
|
"/api/ai/bookslm/history/s-upd",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.json()["title"] == "Après"
|
||||||
|
|
||||||
|
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
|
||||||
|
assert len(listing["sessions"]) == 1
|
||||||
|
assert listing["sessions"][0]["title"] == "Après"
|
||||||
|
|
||||||
|
def test_list_is_sorted_by_updated_at(self, bookslm_client):
|
||||||
|
"""Sessions are listed most recently updated first."""
|
||||||
|
token, _ = _login_bookslm(bookslm_client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
old = self._session_payload("s-old", "Ancienne")
|
||||||
|
old["updatedAt"] = 1000
|
||||||
|
new = self._session_payload("s-new", "Récente")
|
||||||
|
new["updatedAt"] = 5000
|
||||||
|
bookslm_client.put("/api/ai/bookslm/history/s-old", json=old, headers=headers)
|
||||||
|
bookslm_client.put("/api/ai/bookslm/history/s-new", json=new, headers=headers)
|
||||||
|
|
||||||
|
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
|
||||||
|
ids = [s["id"] for s in listing["sessions"]]
|
||||||
|
assert ids == ["s-new", "s-old"]
|
||||||
|
|
||||||
|
def test_get_unknown_session_returns_404(self, bookslm_client):
|
||||||
|
"""GET /history/{id} on an unknown id returns 404."""
|
||||||
|
token, _ = _login_bookslm(bookslm_client)
|
||||||
|
resp = bookslm_client.get(
|
||||||
|
"/api/ai/bookslm/history/s-unknown",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_removes_session(self, bookslm_client):
|
||||||
|
"""DELETE /history/{id} removes the conversation for the current user."""
|
||||||
|
token, _ = _login_bookslm(bookslm_client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
bookslm_client.put(
|
||||||
|
"/api/ai/bookslm/history/s-del",
|
||||||
|
json=self._session_payload("s-del"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
resp = bookslm_client.delete("/api/ai/bookslm/history/s-del", headers=headers)
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
|
||||||
|
assert listing["sessions"] == []
|
||||||
|
assert bookslm_client.get("/api/ai/bookslm/history/s-del", headers=headers).status_code == 404
|
||||||
|
|
||||||
|
def test_history_is_isolated_per_user(self, bookslm_client):
|
||||||
|
"""A session belongs to the user who created it, not to others."""
|
||||||
|
token, _ = _login_bookslm(bookslm_client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
bookslm_client.put(
|
||||||
|
"/api/ai/bookslm/history/s-private",
|
||||||
|
json=self._session_payload("s-private"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provision a second user so isolation can be checked.
|
||||||
|
from backend.auth.password import hash_password
|
||||||
|
|
||||||
|
db = json.loads(Path("data/users.json").read_text(encoding="utf-8"))
|
||||||
|
db["users"]["bob"] = {
|
||||||
|
"id": "bob-1",
|
||||||
|
"username": "bob",
|
||||||
|
"display_name": "Bob",
|
||||||
|
"password_hash": hash_password("BobPass123!"),
|
||||||
|
"role": "admin",
|
||||||
|
"vaults": ["*"],
|
||||||
|
"active": True,
|
||||||
|
"created_at": "2026-01-01T00:00:00",
|
||||||
|
}
|
||||||
|
Path("data/users.json").write_text(json.dumps(db), encoding="utf-8")
|
||||||
|
|
||||||
|
other_token, _ = _login_bookslm(bookslm_client, username="bob", password="BobPass123!")
|
||||||
|
resp = bookslm_client.get(
|
||||||
|
"/api/ai/bookslm/history/s-private",
|
||||||
|
headers={"Authorization": f"Bearer {other_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert bookslm_client.get(
|
||||||
|
"/api/ai/bookslm/history",
|
||||||
|
headers={"Authorization": f"Bearer {other_token}"},
|
||||||
|
).json()["sessions"] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user