From d6fdcef4d4ac5919bdfebb7044a1f240bea66ab7 Mon Sep 17 00:00:00 2001
From: Bruno Charest
Date: Fri, 11 Sep 2026 09:11:00 -0400
Subject: [PATCH] feat(ai): assistant AI multi-contexte + ergonomie sidebar et
clavier
Contexte:
- Repertoire (mode historique BooksLM, via menu contextuel dossier)
- Documents (bouton flottant quand des documents sont ouverts)
- General (bouton flottant sans document: aide app + creation de fichiers)
- Le contexte est affiche dans le header; suggestions adaptees au mode
- Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto)
Backend:
- /api/ai/bookslm/context et /chat acceptent mode + context_files
- collect_files_context(), empty_context(), build_general_system_prompt()
- mode documents degrade vers general si aucun fichier lisible
Corrige:
- bouton flottant passait le chemin d'un fichier comme repertoire -> erreur
'Aucun fichier markdown trouve dans ce dossier'
- panneau precedent masque (localStorage) ne se rouvrait plus
- selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles)
- Entree envoie, Ctrl+Entree insere un saut de ligne
Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
---
CHANGELOG.md | 31 +++
backend/bookslm.py | 166 +++++++++++-
backend/bookslm_routes.py | 111 +++++---
backend/schemas.py | 1 +
docs/ROADMAP.md | 5 +
frontend/js/ai-fab.js | 45 +---
frontend/js/bookslm.js | 497 ++++++++++++++++++++++++++---------
frontend/js/palette.js | 6 +-
frontend/locales/en.json | 23 ++
frontend/locales/fr.json | 23 ++
frontend/style.css | 42 ++-
tests/frontend/ai.test.mjs | 139 +++++++++-
tests/frontend/unit.test.mjs | 2 +-
tests/test_bookslm.py | 172 ++++++++++++
14 files changed, 1049 insertions(+), 214 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd82565..2eedd86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Ajouté
+- **Assistant AI contextuel (sidebar)** — l'assistant s'adapte désormais au contexte d'ouverture :
+ - **Répertoire** (`directory`) : ouvert depuis le menu contextuel d'un dossier — le contexte est
+ le dossier et son contenu (mode historique BooksLM).
+ - **Documents** (`documents`) : ouvert via le bouton flottant quand un ou plusieurs documents
+ sont ouverts — le contexte est la liste des fichiers ouverts (tous onglets/panneaux).
+ - **Général** (`general`) : ouvert via le bouton flottant quand aucun document n'est ouvert —
+ l'assistant répond sur l'application et peut proposer des actions (créer un fichier/dossier)
+ via un bloc `obsigate-action` rendu en carte avec bouton « Appliquer » (aucune exécution
+ automatique).
+ - Le contexte est affiché dans le header de la sidebar (icône, titre, sous-titre) et les
+ suggestions de questions s'adaptent au mode.
+ - Backend : `/api/ai/bookslm/context` et `/chat` acceptent `mode` (`directory`/`documents`/
+ `general`) et `context_files` ; `collect_files_context()`, `empty_context()` et
+ `build_general_system_prompt()` dans `backend/bookslm.py` ; le mode documents dégrade
+ proprement vers le mode général si aucun fichier n'est lisible.
+ - Tests : `tests/test_bookslm.py` (+13 tests modes/documents/général) et
+ `tests/frontend/ai.test.mjs` (+8 tests détection de contexte, clavier, actions, header).
+
- **#72 API publique documentée — OpenAPI 3.1 enrichie** — documentation interactive complète
de l'API REST, générée automatiquement et enrichie.
- Backend `backend/openapi_docs.py` : métadonnées de tags (18 catégories : System, Auth, Files,
@@ -66,6 +84,19 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Corrigé
+- **Assistant AI — sidebar qui ne s'ouvrait plus & erreur de contexte** :
+ - Le bouton flottant passait le **chemin d'un fichier** comme répertoire de contexte, d'où
+ l'erreur « Aucun fichier markdown trouvé dans ce dossier » à l'ouverture sur un document.
+ Le contexte est maintenant détecté automatiquement (documents ouverts → mode documents,
+ sinon mode général).
+ - Un panneau précédemment masqué (`obsigate-bookslm-hidden=true` persisté en localStorage)
+ restait caché lors d'une ouverture explicite → l'ouverture force désormais l'affichage.
+ - **Ergonomie du header** : le sélecteur fournisseur/modèle était sur la même ligne que les
+ actions et masquait ces dernières ; il est déplacé sur une seconde ligne dédiée (toolbar)
+ pour que « nouvelle conversation / export / plein écran / fermer » restent toujours visibles.
+ - **Clavier de la boîte de chat** : `Entrée` envoie la requête, `Ctrl+Entrée` insère un saut
+ de ligne (au lieu de l'inverse), avec un texte d'aide sous le champ.
+
- **Outil AI de l'UI — fiabilisation** :
- **BooksLM (chat IA par répertoire)** : les requêtes `context`/`chat` n'envoyaient pas le
jeton d'authentification (échec 401 quand l'auth est activée) et la réponse SSE était
diff --git a/backend/bookslm.py b/backend/bookslm.py
index 2d7ee80..f0ff266 100644
--- a/backend/bookslm.py
+++ b/backend/bookslm.py
@@ -167,6 +167,7 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
"directory_tree": dir_tree,
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
"max_files": BOOKSLM_MAX_FILES,
+ "scope": "directory",
}
# Store in cache
@@ -175,6 +176,103 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
return result
+def _file_entry(target: Path, rel_path: str, remaining: int) -> dict[str, Any] | None:
+ """Read, redact and truncate a single file into a context entry."""
+ suffix = target.suffix.lower()
+ try:
+ if suffix == ".pdf":
+ from backend.pdf_reader import extract_pdf_text
+
+ content = extract_pdf_text(target)
+ file_type = "pdf"
+ else:
+ content = target.read_text(encoding="utf-8", errors="replace")
+ file_type = "markdown"
+ except Exception as e:
+ logger.warning(f"Cannot read {rel_path}: {e}")
+ return None
+
+ content = redact_file_content(content, rel_path)
+ if len(content) > BOOKSLM_MAX_FILE_CHARS:
+ content = content[:BOOKSLM_MAX_FILE_CHARS] + "\n\n[... tronqué]"
+ if len(content) > remaining:
+ content = content[:remaining] + "\n\n[... tronqué]"
+
+ return {
+ "path": rel_path,
+ "title": target.stem.replace("-", " ").replace("_", " ").title(),
+ "content": content,
+ "type": file_type,
+ }
+
+
+def collect_files_context(
+ vault_path: Path,
+ rel_paths: list[str],
+ scope: str = "documents",
+) -> dict[str, Any]:
+ """Collect an explicit list of files (open documents) as AI context.
+
+ Unlike :func:`collect_directory_context`, this reads only the requested
+ relative paths (markdown or PDF), never the whole directory. Paths outside
+ the vault are silently ignored.
+
+ Args:
+ vault_path: Absolute path to the vault root.
+ rel_paths: Relative file paths within the vault (in display order).
+ scope: Context label exposed to the UI ("documents").
+
+ Returns:
+ Same shape as :func:`collect_directory_context`.
+ """
+ vault_resolved = vault_path.resolve()
+ collected: list[dict[str, Any]] = []
+ total_chars = 0
+ seen: set[str] = set()
+
+ for rel in rel_paths:
+ if len(collected) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
+ break
+ if not rel or rel in seen:
+ continue
+ seen.add(rel)
+ try:
+ target = (vault_resolved / rel).resolve()
+ target.relative_to(vault_resolved)
+ except (ValueError, OSError):
+ continue
+ if not target.is_file():
+ continue
+ entry = _file_entry(target, rel, BOOKSLM_MAX_TOTAL_CHARS - total_chars)
+ if entry is None:
+ continue
+ collected.append(entry)
+ total_chars += len(entry["content"])
+
+ return {
+ "files": collected,
+ "total_chars": total_chars,
+ "file_count": len(collected),
+ "directory_tree": "",
+ "max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
+ "max_files": BOOKSLM_MAX_FILES,
+ "scope": scope,
+ }
+
+
+def empty_context(scope: str = "general") -> dict[str, Any]:
+ """Return an empty context payload (used by the General assistant)."""
+ return {
+ "files": [],
+ "total_chars": 0,
+ "file_count": 0,
+ "directory_tree": "",
+ "max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
+ "max_files": BOOKSLM_MAX_FILES,
+ "scope": scope,
+ }
+
+
def _build_directory_tree(target_dir: Path, vault_root: Path) -> str:
"""Build a text representation of the directory tree (dirs + .md files)."""
lines: list[str] = []
@@ -194,11 +292,12 @@ def _build_directory_tree(target_dir: Path, vault_root: Path) -> str:
return "\n".join(lines)
-def build_system_prompt(context: dict[str, Any]) -> str:
- """Build a system prompt for directory-scoped AI chat.
+def build_system_prompt(context: dict[str, Any], scope: str = "directory") -> str:
+ """Build a system prompt for document-scoped AI chat.
Args:
- context: Output of collect_directory_context().
+ context: Output of collect_directory_context()/collect_files_context().
+ scope: "directory" (whole folder) or "documents" (open files).
Returns:
System prompt string with file contents.
@@ -213,12 +312,26 @@ def build_system_prompt(context: dict[str, Any]) -> str:
if est_tokens > 100_000:
token_warning = f"\n⚠️ Attention : le contexte est très volumineux (~{est_tokens:,} tokens estimés). Les réponses peuvent être moins précises.\n"
+ if scope == "documents":
+ role = (
+ "Tu es un assistant de recherche documentaire intégré à ObsiGate. "
+ "Tu réponds UNIQUEMENT en te basant sur les documents ouverts par l'utilisateur ci-dessous. "
+ "Cite tes sources avec le nom du fichier quand tu utilises une information. "
+ "Si l'information ne se trouve pas dans les documents, dis-le clairement."
+ )
+ context_label = "📄 Documents ouverts"
+ else:
+ role = (
+ "Tu es un assistant de recherche documentaire. "
+ "Tu réponds UNIQUEMENT en te basant sur les documents fournis ci-dessous. "
+ "Cite tes sources avec le nom du fichier quand tu utilises une information. "
+ "Si l'information ne se trouve pas dans les documents, dis-le clairement."
+ )
+ context_label = "📚 Contexte"
+
prompt = (
- "Tu es un assistant de recherche documentaire. "
- "Tu réponds UNIQUEMENT en te basant sur les documents fournis ci-dessous. "
- "Cite tes sources avec le nom du fichier quand tu utilises une information. "
- "Si l'information ne se trouve pas dans les documents, dis-le clairement."
- f"\n\n📚 Contexte : {file_count} fichier(s) ({total_chars:,} caractères)"
+ f"{role}"
+ f"\n\n{context_label} : {file_count} fichier(s) ({total_chars:,} caractères)"
f"{token_warning}\n"
)
@@ -237,6 +350,43 @@ def build_system_prompt(context: dict[str, Any]) -> str:
return prompt
+GENERAL_SYSTEM_PROMPT = """Tu es l'assistant intégré d'ObsiGate, une application web auto-hébergée pour consulter, rechercher et éditer des vaults Obsidian (Markdown).
+
+Tes deux rôles :
+1. **Aider sur l'application** : expliquer la navigation, la recherche (full-text, filtres `tag:`, `created:`, `path:`), l'éditeur (CodeMirror, autosave, raccourcis), les onglets et le split view, les sauvegardes et la restauration, le partage public, l'export (HTML/Markdown/ePub/PDF), Mermaid, Excalidraw, les plugins, les thèmes, le mode hors-ligne, le MFA, etc.
+2. **Proposer des actions concrètes** : créer un fichier ou un dossier dans un vault.
+
+Quand l'utilisateur demande explicitement de créer un fichier, inclus EXACTEMENT un bloc de ce type dans ta réponse (et rien d'autre à l'intérieur du bloc) :
+
+```obsigate-action
+{"action": "create_file", "vault": "", "path": "", "content": ""}
+```
+
+Pour créer un dossier :
+
+```obsigate-action
+{"action": "create_directory", "vault": "", "path": ""}
+```
+
+Règles :
+- Ne propose une action que si l'utilisateur la demande explicitement.
+- Explique en une phrase ce que fait l'action avant le bloc.
+- Utilise un chemin relatif se terminant par `.md` pour un fichier.
+- N'invente jamais un nom de vault : utilise l'un des vaults disponibles listés ci-dessous.
+- Réponds dans la langue de l'utilisateur, de façon concise et structurée (Markdown).
+"""
+
+
+def build_general_system_prompt(vaults: list[str] | None = None) -> str:
+ """System prompt for the General assistant (app help + actions)."""
+ prompt = GENERAL_SYSTEM_PROMPT
+ if vaults:
+ prompt += "\nVaults disponibles : " + ", ".join(sorted(vaults)) + "\n"
+ else:
+ prompt += "\nAucun vault n'est actuellement configuré.\n"
+ return prompt
+
+
def invalidate_cache(vault_path: Path | None = None, directory: str | None = None) -> int:
"""Invalidate cache entries.
diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py
index a3b9a1c..613b827 100644
--- a/backend/bookslm_routes.py
+++ b/backend/bookslm_routes.py
@@ -9,25 +9,43 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from backend.auth.middleware import check_vault_access, require_auth
-from backend.bookslm import build_system_prompt, collect_directory_context
-from backend.indexer import get_vault_data
+from backend.bookslm import (
+ build_general_system_prompt,
+ build_system_prompt,
+ collect_directory_context,
+ collect_files_context,
+ empty_context,
+)
+from backend.indexer import get_vault_data, index
from backend.schemas import BooksLMContextResponse
logger = logging.getLogger("obsigate.bookslm_routes")
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
+VALID_MODES = {"directory", "documents", "general"}
+
# ── Request models ──
class BooksLMContextRequest(BaseModel):
- vault: str = Field(description="Vault name")
+ vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
directory: str = Field(default="", description="Relative directory path within the vault")
+ mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
+ context_files: list[str] = Field(
+ default_factory=list,
+ description="Relative file paths to use as context (documents mode)",
+ )
class BooksLMChatRequest(BaseModel):
- vault: str = Field(description="Vault name")
+ vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
directory: str = Field(default="", description="Relative directory path within the vault")
+ mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
+ context_files: list[str] = Field(
+ default_factory=list,
+ description="Relative file paths to use as context (documents mode)",
+ )
message: str = Field(description="User message")
conversation_history: list[dict[str, str]] = Field(
default_factory=list,
@@ -44,6 +62,34 @@ class BooksLMChatRequest(BaseModel):
)
+def _normalize_mode(mode: str | None) -> str:
+ mode = (mode or "directory").lower()
+ return mode if mode in VALID_MODES else "directory"
+
+
+def _resolve_vault_path(vault: str | None, current_user):
+ """Validate vault access and return (vault_name, vault_path)."""
+ if not vault:
+ raise HTTPException(status_code=400, detail="Champ 'vault' requis pour ce contexte")
+ if not check_vault_access(vault, current_user):
+ raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
+ vault_data = get_vault_data(vault)
+ if not vault_data:
+ raise HTTPException(status_code=404, detail=f"Vault '{vault}' not found")
+ return vault, Path(vault_data["path"])
+
+
+def _build_context(mode: str, vault_path: Path | None, directory: str, context_files: list[str]):
+ """Collect the context payload for the requested mode."""
+ if mode == "general":
+ return empty_context("general")
+ if mode == "documents":
+ ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type]
+ # No open document could be read → degrade gracefully to General.
+ return ctx if ctx["file_count"] else empty_context("general")
+ return collect_directory_context(vault_path, directory) # type: ignore[arg-type]
+
+
# ── Endpoints ──
@@ -52,20 +98,21 @@ async def api_bookslm_context(
req: BooksLMContextRequest,
current_user=Depends(require_auth),
):
- """Collect directory context for BooksLM.
+ """Collect context for the AI assistant.
- Returns file list, content, and metadata for the specified directory.
+ Three modes are supported:
+
+ * ``directory`` — every supported file under a vault directory;
+ * ``documents`` — only the explicitly listed open documents;
+ * ``general`` — no document context (assistant for the app itself).
"""
- if not check_vault_access(req.vault, current_user):
- raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
+ mode = _normalize_mode(req.mode)
+ if mode == "general":
+ return empty_context("general")
- vault_data = get_vault_data(req.vault)
- if not vault_data:
- raise HTTPException(status_code=404, detail=f"Vault '{req.vault}' not found")
-
- vault_path = Path(vault_data["path"])
- context = collect_directory_context(vault_path, req.directory)
- return context
+ _resolve_vault_path(req.vault, current_user)
+ vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
+ return _build_context(mode, vault_path, req.directory, req.context_files)
@router.post(
@@ -77,28 +124,32 @@ async def api_bookslm_chat(
req: BooksLMChatRequest,
current_user=Depends(require_auth),
):
- """Chat with AI about directory contents (BooksLM).
+ """Chat with the AI assistant.
- Builds context from the directory, then sends the user message
- with a system prompt containing all file contents to the AI provider.
- Returns an SSE stream with the response.
+ Builds a system prompt from the selected context (directory, open
+ documents or general app knowledge), then streams the provider's answer
+ as Server-Sent Events.
"""
- if not check_vault_access(req.vault, current_user):
- raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
+ mode = _normalize_mode(req.mode)
- vault_data = get_vault_data(req.vault)
- if not vault_data:
- raise HTTPException(status_code=404, detail=f"Vault '{req.vault}' not found")
-
- vault_path = Path(vault_data["path"])
+ vault_path: Path | None = None
+ if mode != "general":
+ _resolve_vault_path(req.vault, current_user)
+ vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
# Collect context
- context = collect_directory_context(vault_path, req.directory)
- if context["file_count"] == 0:
- raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
+ context = _build_context(mode, vault_path, req.directory, req.context_files)
+ effective_mode = context.get("scope", mode)
# Build system prompt
- system_prompt = build_system_prompt(context)
+ if effective_mode == "general":
+ system_prompt = build_general_system_prompt(list(index.keys()))
+ elif effective_mode == "documents":
+ system_prompt = build_system_prompt(context, scope="documents")
+ else:
+ if context["file_count"] == 0:
+ raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
+ system_prompt = build_system_prompt(context, scope="directory")
# Call AI provider
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini
diff --git a/backend/schemas.py b/backend/schemas.py
index 01b9995..aea7fe7 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -509,4 +509,5 @@ class BooksLMContextResponse(BaseModel):
directory_tree: str = ""
max_total_chars: int = Field(default=200000, description="Configured context character limit")
max_files: int = Field(default=200, description="Configured file-count limit")
+ scope: str = Field(default="directory", description="Context scope: 'directory', 'documents' or 'general'")
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index dad8ea4..af3828a 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -443,6 +443,11 @@
---
### 76. BooksLM — Console AI contextuelle par répertoire (style NotebookLM) — ✅ TERMINÉ
+> **Extension (2026-09-11) :** l'assistant AI est désormais **multi-contexte** — Répertoire
+> (mode historique), Documents (documents ouverts, via le bouton flottant) et Général (aucun
+> document ouvert : aide sur l'app + création de fichiers/dossiers par cartes d'action). Header
+> refondu (sélecteur fournisseur/modèle sur une ligne dédiée), `Entrée` envoie / `Ctrl+Entrée`
+> insère un saut de ligne. Voir CHANGELOG.
- **Effort :** 5-6 jours (réalisé) | **Impact :** 🟡
- **Description :** Console de chat AI contextuelle accessible via le menu contextuel des répertoires dans l'arborescence. Au clic sur « BooksLM », un panneau de chat s'ouvre à droite du viewer et indexe automatiquement toutes les ressources markdown (et PDF via #74) du répertoire courant et de ses sous-répertoires récursivement comme contexte pour un assistant AI. L'assistant peut répondre à des questions, résumer, synthétiser, et croiser l'information à travers tous les documents du scope — exactement comme NotebookLM de Google, mais pour n'importe quel répertoire de votre vault Obsidian.
- **Fonctionnement général :**
diff --git a/frontend/js/ai-fab.js b/frontend/js/ai-fab.js
index 382ab83..554648e 100644
--- a/frontend/js/ai-fab.js
+++ b/frontend/js/ai-fab.js
@@ -1,26 +1,14 @@
-// ObsiGate — Floating Action Button (FAB) to open/close the BooksLM AI sidebar.
-// Always visible in the bottom-right corner; hides while BooksLM is open.
+// ObsiGate — Floating Action Button (FAB) for the AI Assistant sidebar.
+// Always visible in the bottom-right corner; hides while the assistant panel
+// is expanded. Clicking it opens the assistant in the context that matches
+// the current application state (open documents, or General when none).
import booksLM from './bookslm.js';
-import { state } from './state.js';
-import { showToast, TabManager } from './ui.js';
import { t } from './i18n.js';
import { safeCreateIcons } from './utils.js';
let _fab = null;
-// Mirror palette.js getCurrentFile(): prefer the active tab, then fall back to state.
-function getCurrentFile() {
- const id = TabManager._activeTabId;
- if (id) {
- const tab = TabManager._tabs.find((x) => x.id === id);
- if (tab) return { vault: tab.vault, path: tab.path };
- }
- if (state.currentVault && state.currentPath) return { vault: state.currentVault, path: state.currentPath };
- return null;
-}
-
-// The BooksLM panel is considered visible when it has `.open` without `.hidden`
-// (the header toggle collapses it off-screen via `.hidden`).
+// The panel is visible when it has `.open` without `.hidden`.
function isBooksLmVisible() {
const panel = document.querySelector('.bookslm-panel');
return !!(panel && panel.classList.contains('open') && !panel.classList.contains('hidden'));
@@ -36,25 +24,14 @@ function syncVisibility() {
}
async function onClick() {
- const f = getCurrentFile();
- if (!f) {
- showToast(t('toast.no_open_file'), 'error');
+ const panel = document.querySelector('.bookslm-panel');
+ // Collapsed panel: reveal it instead of reopening.
+ if (panel && panel.classList.contains('open') && panel.classList.contains('hidden')) {
+ booksLM._toggleSidebar();
return;
}
-
- const sameDir = booksLM._isOpen && booksLM._vault === f.vault && booksLM._directory === f.path;
- if (sameDir) {
- // Already open for this directory: re-show a collapsed panel, or close it.
- const panel = document.querySelector('.bookslm-panel');
- if (panel && panel.classList.contains('hidden')) {
- booksLM._toggleSidebar();
- } else {
- booksLM.close();
- }
- return;
- }
-
- await booksLM.open(f.vault, f.path);
+ // Otherwise open (or re-focus) the assistant for the current context.
+ await booksLM.openForCurrentContext();
}
export function initAIFab() {
diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js
index dbeb07e..667c890 100644
--- a/frontend/js/bookslm.js
+++ b/frontend/js/bookslm.js
@@ -1,14 +1,62 @@
-// BooksLM — Directory-scoped AI chat panel (style NotebookLM)
+// ObsiGate — AI Assistant sidebar.
+//
+// The panel adapts to three contexts:
+// • directory — a vault folder (opened from the tree context menu)
+// • documents — the file(s) currently open in tabs/panes
+// • general — no document open: app help + file/directory creation actions
import { t } from './i18n.js';
import { buildAIPickerUI } from './ai.js';
import { safeCreateIcons } from './utils.js';
import { api, AuthManager } from './auth.js';
+import { showToast } from './ui.js';
+
+export const MODE = Object.freeze({
+ DIRECTORY: 'directory',
+ DOCUMENTS: 'documents',
+ GENERAL: 'general',
+});
+
+/**
+ * Gather the files currently open across the singleton TabManager and every
+ * split-view pane (window globals avoid a circular import with ui.js).
+ * The active tab of each pane is listed first.
+ */
+export function collectOpenDocuments() {
+ const docs = [];
+ const seen = new Set();
+ const add = (vault, path) => {
+ if (!vault || !path) return;
+ const key = `${vault}\u0000${path}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ docs.push({ vault, path });
+ };
+ const scan = (tm) => {
+ if (!tm || !Array.isArray(tm._tabs)) return;
+ const active = tm._tabs.find((x) => x.id === tm._activeTabId);
+ if (active) add(active.vault, active.path);
+ for (const tab of tm._tabs) add(tab.vault, tab.path);
+ };
+ try {
+ scan(window.TabManager);
+ const pm = window.PaneManager;
+ if (pm && pm.tabManagers) Object.values(pm.tabManagers).forEach(scan);
+ } catch { /* not ready yet */ }
+ return docs;
+}
+
+export function documentsSignature(documents) {
+ return (documents || []).map((d) => `${d.vault}/${d.path}`).join('\u0000');
+}
class BooksLM {
constructor() {
this._isOpen = false;
+ this._mode = MODE.GENERAL;
this._vault = null;
this._directory = null;
+ this._documents = [];
+ this._effectiveScope = MODE.GENERAL;
this._messages = [];
this._contextFiles = [];
this._isLoading = false;
@@ -19,100 +67,121 @@ class BooksLM {
// ── Public API ──────────────────────────────────────────────────────
- async open(vault, directory) {
- if (this._isOpen && this._vault === vault && this._directory === directory) {
- this.close();
+ /**
+ * Open the assistant for a specific context.
+ * @param {object} opts
+ * @param {'directory'|'documents'|'general'} opts.mode
+ * @param {string|null} [opts.vault]
+ * @param {string} [opts.directory]
+ * @param {Array<{vault:string,path:string}>} [opts.documents]
+ */
+ async openContext(opts = {}) {
+ const mode = opts.mode || MODE.GENERAL;
+ const vault = opts.vault || null;
+ const directory = opts.directory || '';
+ const documents = Array.isArray(opts.documents) ? opts.documents : [];
+
+ const sameContext =
+ this._isOpen &&
+ this._mode === mode &&
+ this._vault === vault &&
+ this._directory === directory &&
+ documentsSignature(this._documents) === documentsSignature(documents);
+
+ if (sameContext) {
+ // Already open for this context: reveal a collapsed panel, else close.
+ if (this._panel && this._panel.classList.contains('hidden')) {
+ this._setHidden(false);
+ document.dispatchEvent(new CustomEvent('bookslm:opened'));
+ } else {
+ this.close();
+ }
return;
}
+
+ this._mode = mode;
this._vault = vault;
this._directory = directory;
+ this._documents = documents;
+ this._effectiveScope = mode;
this._messages = [];
this._contextFiles = [];
this._isLoading = true;
-
- // Restore history
this._loadHistory();
- // Build panel if needed
if (!this._panel) {
this._panel = this._render();
document.body.appendChild(this._panel);
+ if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
+
+ // An explicit open always reveals the panel, even if it was collapsed
+ // in a previous session (previously it stayed hidden → "ne s'ouvre plus").
+ this._setHidden(false);
this._updateHeader();
this._updateStatus();
this._renderMessages();
+ this._showSuggestions();
- // Open with animation
requestAnimationFrame(() => {
this._panel.classList.add('open');
- let hidden = false;
- // Apply persisted hidden state (user toggled off in a previous session)
- try {
- const savedHidden = localStorage.getItem('obsigate-bookslm-hidden');
- if (savedHidden === 'true') {
- this._panel.classList.add('hidden');
- this._updateToggleIcon();
- hidden = true;
- }
- } catch { /* */ }
- document.dispatchEvent(new CustomEvent(hidden ? 'bookslm:closed' : 'bookslm:opened', { detail: { vault, directory } }));
+ document.dispatchEvent(new CustomEvent('bookslm:opened', {
+ detail: { mode, vault, directory, documents },
+ }));
});
this._isOpen = true;
- // Load context (authenticated — `api` injects the bearer token and
- // transparently refreshes it once on 401).
- try {
- const data = await api('/api/ai/bookslm/context', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ vault, directory }),
- });
- this._contextFiles = data.files || [];
- this._updateStatus(data);
- this._showSuggestions();
- } catch (e) {
- console.warn('BooksLM: context load failed', e);
- this._contextFiles = [];
- this._updateStatus({ error: e.message });
- }
+ await this._loadContext();
this._isLoading = false;
}
+ /** Backwards-compatible helper: open the assistant on a directory. */
+ async open(vault, directory) {
+ return this.openContext({ mode: MODE.DIRECTORY, vault, directory });
+ }
+
+ /**
+ * Open the assistant using the current application context: the open
+ * document(s) if any, otherwise the General assistant.
+ */
+ async openForCurrentContext() {
+ const docs = collectOpenDocuments();
+ if (docs.length) {
+ const vault = docs[0].vault;
+ const scoped = docs.filter((d) => d.vault === vault);
+ return this.openContext({ mode: MODE.DOCUMENTS, vault, documents: scoped });
+ }
+ return this.openContext({ mode: MODE.GENERAL });
+ }
+
close() {
if (!this._panel || !this._isOpen) return;
this._panel.classList.remove('open');
this._isOpen = false;
document.dispatchEvent(new CustomEvent('bookslm:closed'));
- // Abort any in-flight request
if (this._abortCtrl) {
this._abortCtrl.abort();
this._abortCtrl = null;
}
}
- /**
- * Toggle the BooksLM sidebar visibility (collapsed/hidden to the right edge
- * while the panel stays mounted). The state is persisted in localStorage so
- * the user choice survives page reloads — same UX as the left sidebar toggle.
- *
- * The panel stays in the DOM (no teardown); only a CSS transform is applied,
- * which is what makes this fast and matches the right-sidebar pattern in
- * ObsiGate.
- */
- _toggleSidebar() {
+ _setHidden(hidden) {
if (!this._panel) return;
- const isHidden = this._panel.classList.toggle('hidden');
+ this._panel.classList.toggle('hidden', hidden);
try {
- localStorage.setItem('obsigate-bookslm-hidden', isHidden ? 'true' : 'false');
+ localStorage.setItem('obsigate-bookslm-hidden', hidden ? 'true' : 'false');
} catch { /* */ }
this._updateToggleIcon();
+ }
+
+ /** Collapse/expand while keeping the panel mounted (header toggle button). */
+ _toggleSidebar() {
+ if (!this._panel) return;
+ const isHidden = !this._panel.classList.contains('hidden');
+ this._setHidden(isHidden);
document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened'));
}
- /**
- * Swap the toggle icon between "panel-right-close" (visible) and
- * "panel-right-open" (hidden) so the button visually reflects the state.
- */
_updateToggleIcon() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-toggle');
@@ -131,14 +200,12 @@ class BooksLM {
this._renderMessages();
this._showSuggestions();
}
- if (!this._isOpen && this._vault && this._directory) {
- this.open(this._vault, this._directory);
- }
}
exportConversation() {
if (!this._messages.length) return;
- let md = `# BooksLM — ${this._directory}\n\n`;
+ const label = this._contextTitle() || 'assistant';
+ let md = `# ${label}\n\n`;
for (const msg of this._messages) {
const role = msg.role === 'user' ? '**You**' : '**AI**';
md += `### ${role}\n\n${msg.content}\n\n---\n\n`;
@@ -146,17 +213,79 @@ class BooksLM {
const blob = new Blob([md], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
- a.download = `bookslm-${this._directory.replace(/\//g, '_')}.md`;
+ a.download = `obsigate-assistant-${label.replace(/[^\w.-]+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(a.href);
}
openFile(path) {
window.dispatchEvent(new CustomEvent('obsigate:open-file', {
- detail: { vault: this._vault, path }
+ detail: { vault: this._vault, path },
}));
}
+ // ── Context ─────────────────────────────────────────────────────────
+
+ async _loadContext() {
+ if (this._mode === MODE.GENERAL) {
+ this._effectiveScope = MODE.GENERAL;
+ this._contextFiles = [];
+ this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 });
+ this._showSuggestions();
+ return;
+ }
+
+ const payload = {
+ mode: this._mode,
+ vault: this._vault,
+ directory: this._directory,
+ context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
+ };
+ try {
+ const data = await api('/api/ai/bookslm/context', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ this._contextFiles = data.files || [];
+ this._effectiveScope = data.scope || this._mode;
+ this._updateStatus(data);
+ this._showSuggestions();
+ } catch (e) {
+ console.warn('AI assistant: context load failed', e);
+ this._contextFiles = [];
+ this._updateStatus({ error: e.message });
+ }
+ }
+
+ _contextMeta() {
+ if (this._mode === MODE.DOCUMENTS) {
+ const names = this._documents.map((d) => (d.path || '').split('/').pop()).filter(Boolean);
+ return {
+ icon: 'file-text',
+ title: t('ai.context_documents', { count: this._documents.length }),
+ subtitle: names.slice(0, 3).join(', ') + (names.length > 3 ? '…' : ''),
+ };
+ }
+ if (this._mode === MODE.DIRECTORY) {
+ const name = this._directory ? this._directory.split('/').filter(Boolean).pop() : this._vault;
+ return {
+ icon: 'folder',
+ title: name || this._vault || t('bookslm.title'),
+ subtitle: `${this._vault}${this._directory ? '/' + this._directory : ''}`,
+ };
+ }
+ return {
+ icon: 'sparkles',
+ title: t('ai.context_general'),
+ subtitle: t('ai.context_general_desc'),
+ };
+ }
+
+ _contextTitle() {
+ return this._contextMeta().title;
+ }
+
// ── Rendering ───────────────────────────────────────────────────────
_render() {
@@ -167,30 +296,41 @@ class BooksLM {
- 📚
-
-
-
-
-
-
+
+
+
+
+
+ ${t('ai.model_settings')}
+
-
-
+
+
+ ${t('ai.input_hint')}
`;
- // Inject the provider/model picker asynchronously (depends on /api/ai/status)
+ // Provider/model picker lives on its own toolbar row so the header
+ // actions stay visible regardless of the provider/model text width.
const pickerHost = panel.querySelector('.bookslm-picker-host');
buildAIPickerUI().then((picker) => {
- if (picker && pickerHost) pickerHost.replaceWith(picker);
+ if (!picker || !pickerHost) return;
+ pickerHost.replaceWith(picker);
+ if (typeof safeCreateIcons === 'function') safeCreateIcons();
}).catch(() => { /* ignore */ });
- // Wire events
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation());
@@ -198,22 +338,36 @@ class BooksLM {
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
this._isFullscreen = !this._isFullscreen;
panel.classList.toggle('fullscreen', this._isFullscreen);
+ const icon = panel.querySelector('.bookslm-btn-fullscreen i');
+ if (icon) {
+ icon.setAttribute('data-lucide', this._isFullscreen ? 'minimize-2' : 'maximize-2');
+ if (typeof safeCreateIcons === 'function') safeCreateIcons();
+ }
});
- // Send button
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
- // Textarea auto-grow + Ctrl+Enter
const textarea = panel.querySelector('textarea');
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
- textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
+ textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
});
textarea.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
+ if (e.key !== 'Enter') return;
+ if (e.ctrlKey || e.metaKey) {
+ // Ctrl/Cmd+Enter → insert a newline at the caret.
e.preventDefault();
- this._sendMessage();
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ textarea.value = textarea.value.slice(0, start) + '\n' + textarea.value.slice(end);
+ textarea.selectionStart = textarea.selectionEnd = start + 1;
+ textarea.dispatchEvent(new Event('input'));
+ return;
}
+ // Enter (with or without Shift) → submit. Shift+Enter also submits so
+ // the documented shortcut (Ctrl+Enter) is the only way to add a line.
+ e.preventDefault();
+ this._sendMessage();
});
return panel;
@@ -221,12 +375,14 @@ class BooksLM {
_updateHeader() {
if (!this._panel) return;
+ const meta = this._contextMeta();
const title = this._panel.querySelector('.bookslm-title');
- if (title) {
- title.textContent = this._directory
- ? `📚 ${this._directory.split('/').pop() || this._vault}`
- : t('bookslm.title');
- }
+ const subtitle = this._panel.querySelector('.bookslm-subtitle');
+ const icon = this._panel.querySelector('.bookslm-context-icon i');
+ if (title) title.textContent = meta.title;
+ if (subtitle) subtitle.textContent = meta.subtitle || '';
+ if (icon) icon.setAttribute('data-lucide', meta.icon);
+ if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
_updateStatus(data) {
@@ -235,37 +391,56 @@ class BooksLM {
if (!status) return;
if (data && data.error) {
- status.innerHTML = `⚠ ${t('bookslm.no_context')}`;
+ status.innerHTML = `⚠ ${t('bookslm.no_context')}`;
return;
}
- if (data && data.files) {
+ if (data && Array.isArray(data.files)) {
const count = data.files.length;
const chars = data.total_chars || 0;
const maxChars = data.max_total_chars || 200000;
const pct = Math.min(100, Math.round((chars / maxChars) * 100));
+ const label = data.scope === MODE.GENERAL
+ ? t('ai.context_general_desc')
+ : `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`;
status.innerHTML = `
- ${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}
+ ${label}
`;
} else if (this._isLoading) {
- status.textContent = '⏳ ...';
+ status.textContent = '⏳ …';
}
}
+ _suggestionsForContext() {
+ if (this._mode === MODE.GENERAL) {
+ return [
+ t('ai.suggestion_capabilities'),
+ t('ai.suggestion_search'),
+ t('ai.suggestion_create_file'),
+ ];
+ }
+ if (this._mode === MODE.DOCUMENTS) {
+ return [
+ t('ai.suggestion_docs_summary'),
+ t('ai.suggestion_docs_keypoints'),
+ t('ai.suggestion_docs_contradictions'),
+ ];
+ }
+ return [
+ t('bookslm.suggestion_summary'),
+ t('bookslm.suggestion_themes'),
+ t('bookslm.suggestion_contradictions'),
+ ];
+ }
+
_showSuggestions() {
if (!this._panel) return;
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (!sugEl) return;
sugEl.innerHTML = '';
+ if (this._messages.length) return;
- if (!this._contextFiles.length && !this._isLoading) return;
-
- const suggestions = [
- t('bookslm.suggestion_summary'),
- t('bookslm.suggestion_themes'),
- t('bookslm.suggestion_contradictions')
- ];
- for (const s of suggestions) {
+ for (const s of this._suggestionsForContext()) {
const btn = document.createElement('button');
btn.className = 'bookslm-suggestion';
btn.textContent = s;
@@ -291,8 +466,14 @@ class BooksLM {
bubble.className = `bookslm-bubble ${msg.role}`;
if (msg.role === 'assistant') {
- bubble.innerHTML = this._renderMarkdown(msg.content || '');
- // Source badges
+ const { text, actions } = this._extractActions(msg.content || '');
+ bubble.innerHTML = this._renderMarkdown(text);
+ if (actions.length) {
+ const actionsDiv = document.createElement('div');
+ actionsDiv.className = 'bookslm-actions';
+ actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
+ bubble.appendChild(actionsDiv);
+ }
if (msg.sources && msg.sources.length) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'bookslm-sources';
@@ -313,10 +494,86 @@ class BooksLM {
container.appendChild(bubble);
}
- // Scroll to bottom
container.scrollTop = container.scrollHeight;
}
+ // ── Actions (general assistant) ─────────────────────────────────────
+
+ _extractActions(text) {
+ const actions = [];
+ const re = /```obsigate-action\s*\n([\s\S]*?)```/g;
+ let match;
+ while ((match = re.exec(text)) !== null) {
+ try {
+ const obj = JSON.parse(match[1].trim());
+ if (obj && typeof obj.action === 'string') actions.push(obj);
+ } catch { /* malformed block — ignore */ }
+ }
+ return { text: text.replace(re, '').trim(), actions };
+ }
+
+ _renderActionCard(action) {
+ const card = document.createElement('div');
+ card.className = 'bookslm-action';
+ const isDir = action.action === 'create_directory';
+ const label = isDir
+ ? t('ai.action_create_dir', { path: action.path || '' })
+ : t('ai.action_create_file', { path: action.path || '' });
+ const meta = document.createElement('div');
+ meta.className = 'bookslm-action-meta';
+ meta.innerHTML = `${isDir ? '📁' : '📄'}`;
+ const textEl = document.createElement('span');
+ textEl.textContent = label;
+ meta.appendChild(textEl);
+ card.appendChild(meta);
+
+ const apply = document.createElement('button');
+ apply.className = 'bookslm-action-apply';
+ apply.textContent = t('ai.action_apply');
+ apply.addEventListener('click', async () => {
+ apply.disabled = true;
+ apply.textContent = t('ai.action_applying');
+ try {
+ await this._applyAction(action);
+ card.classList.add('done');
+ apply.textContent = t('ai.action_applied');
+ } catch (e) {
+ apply.disabled = false;
+ apply.textContent = t('ai.action_apply');
+ showToast(t('ai.action_failed', { error: e.message }), 'error');
+ }
+ });
+ card.appendChild(apply);
+ return card;
+ }
+
+ async _applyAction(action) {
+ if (!action.vault || !action.path) throw new Error('missing vault/path');
+ if (action.action === 'create_file') {
+ await api(`/api/file/${encodeURIComponent(action.vault)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: action.path, content: action.content || '' }),
+ });
+ showToast(t('ai.action_created_file', { path: action.path }), 'success');
+ } else if (action.action === 'create_directory') {
+ await api(`/api/directory/${encodeURIComponent(action.vault)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: action.path }),
+ });
+ showToast(t('ai.action_created_dir', { path: action.path }), 'success');
+ } else {
+ throw new Error(`unknown action: ${action.action}`);
+ }
+ try {
+ const m = await import('./sidebar.js');
+ if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
+ await m.refreshSidebarTreePreservingState();
+ }
+ } catch { /* tree refresh is best-effort */ }
+ }
+
// ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() {
@@ -327,34 +584,28 @@ class BooksLM {
textarea.value = '';
textarea.style.height = 'auto';
- // Hide suggestions
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (sugEl) sugEl.innerHTML = '';
- // Snapshot the prior turns BEFORE appending the new user message so the
- // backend receives the correct `message` + `conversation_history` split.
+ // Snapshot prior turns BEFORE appending the new user message.
const history = this._messages
.filter((m) => m.content && m.content.trim())
.map((m) => ({ role: m.role, content: m.content }));
- // Add user message
this._messages.push({ role: 'user', content: text });
this._renderMessages();
- // Add assistant placeholder
const assistantMsg = { role: 'assistant', content: '', sources: [] };
this._messages.push(assistantMsg);
this._renderMessages();
this._isLoading = true;
- // Disable send button
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
try {
- // Read provider/model from the picker (localStorage-backed)
let provider = null;
let model = null;
try {
@@ -364,17 +615,16 @@ class BooksLM {
} catch { /* */ }
const payload = {
+ mode: this._mode,
vault: this._vault,
directory: this._directory,
+ context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
message: text,
conversation_history: history,
- context_files: this._contextFiles.map((f) => f.path || f),
provider,
model,
};
- // Authenticated streaming request (raw fetch so we can read the SSE
- // body; `api()` would consume the JSON response instead).
const doFetch = () => {
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch('/api/ai/bookslm/chat', {
@@ -421,8 +671,6 @@ class BooksLM {
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
return;
}
- // The backend streams `{token: "..."}`; accept `content` too for
- // forward compatibility.
const chunk = data.token || data.content || '';
if (chunk) assistantMsg.content += chunk;
if (data.sources) assistantMsg.sources = data.sources;
@@ -431,31 +679,27 @@ class BooksLM {
while (true) {
const { done, value } = await reader.read();
if (done) break;
-
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
-
for (const line of lines) handleLine(line);
this._renderMessages();
}
if (buffer) handleLine(buffer);
- // Extract sources from context files if mentioned
- if (!assistantMsg.sources.length) {
+ if (!assistantMsg.sources.length && this._contextFiles.length) {
const contentLower = assistantMsg.content.toLowerCase();
assistantMsg.sources = this._contextFiles
- .filter(f => {
+ .filter((f) => {
const name = (f.path || f || '').split('/').pop().toLowerCase();
return name && contentLower.includes(name);
})
- .map(f => f.path || f);
+ .map((f) => f.path || f);
}
-
} catch (e) {
if (e.name !== 'AbortError') {
assistantMsg.content = `⚠ Error: ${e.message}`;
- console.warn('BooksLM chat error:', e);
+ console.warn('AI assistant chat error:', e);
}
}
@@ -470,23 +714,19 @@ class BooksLM {
_renderMarkdown(text) {
if (!text) return '';
+ // Escape HTML first so model output cannot inject markup.
let html = text
- // Code blocks
+ .replace(/&/g, '&')
+ .replace(//g, '>')
.replace(/```(\w*)\n([\s\S]*?)```/g, '$2
')
- // Inline code
.replace(/`([^`]+)`/g, '$1')
- // Bold
.replace(/\*\*(.+?)\*\*/g, '$1')
- // Italic
.replace(/\*(.+?)\*/g, '$1')
- // Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
- // Unordered lists
.replace(/^[*\-+] (.+)$/gm, '$1')
- // Headers
.replace(/^### (.+)$/gm, '$1
')
.replace(/^## (.+)$/gm, '$1
')
- // Paragraphs
.replace(/\n\n/g, '
')
.replace(/\n/g, '
');
return `
${html}
`;
@@ -495,21 +735,22 @@ class BooksLM {
// ── History persistence ─────────────────────────────────────────────
_historyKey() {
- return `bookslm-history-${this._vault}-${this._directory}`;
+ const ctx = this._mode === MODE.DOCUMENTS
+ ? documentsSignature(this._documents)
+ : (this._directory || this._vault || 'general');
+ return `bookslm-history-${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
}
_saveHistory() {
try {
localStorage.setItem(this._historyKey(), JSON.stringify(this._messages));
- } catch {}
+ } catch { /* */ }
}
_loadHistory() {
try {
const data = localStorage.getItem(this._historyKey());
- if (data) {
- this._messages = JSON.parse(data);
- }
+ this._messages = data ? JSON.parse(data) : [];
} catch {
this._messages = [];
}
diff --git a/frontend/js/palette.js b/frontend/js/palette.js
index 44c4582..11614b4 100644
--- a/frontend/js/palette.js
+++ b/frontend/js/palette.js
@@ -58,9 +58,9 @@ function getCMDS() {
{ id:'focus-next', label:'→ Panneau suivant', desc:'Active le panneau suivant (Ctrl+Alt+→)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId+1)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
{ id:'focus-prev', label:'← Panneau précédent', desc:'Active le panneau précédent (Ctrl+Alt+←)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId-1+window.PaneManager.panes.length)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
{ id:'reset-panes', label:'🔄 Réinitialiser les panneaux', desc:'Ferme tous les panneaux et revient au mode single-pane', cat:'Panneaux', act:()=>{close();if(window.PaneManager){while(window.PaneManager.isSplit()){window.PaneManager.closePane(window.PaneManager.panes.length-1);}localStorage.removeItem('obsigate-panes');}} },
- // BooksLM commands
- { id:'bookslm-open', label:t('palette.bookslm_open'), desc:'Open BooksLM AI chat for current directory', cat:'AI', act:async()=>{close();const f=getCurrentFile();if(f){const m=await import('./bookslm.js');m.default.open(f.vault,f.path);}else{showToast(t('toast.no_open_file'),'error');}} },
- { id:'bookslm-new', label:t('palette.bookslm_new'), desc:'Reset BooksLM conversation and open fresh', cat:'AI', act:async()=>{close();const f=getCurrentFile();if(f){const m=await import('./bookslm.js');m.default.newConversation();m.default.open(f.vault,f.path);}else{showToast(t('toast.no_open_file'),'error');}} },
+ // AI Assistant commands
+ { id:'bookslm-open', label:t('palette.bookslm_open'), desc:t('palette.bookslm_open_desc'), cat:'AI', act:async()=>{close();const m=await import('./bookslm.js');m.default.openForCurrentContext();} },
+ { id:'bookslm-new', label:t('palette.bookslm_new'), desc:t('palette.bookslm_new_desc'), cat:'AI', act:async()=>{close();const m=await import('./bookslm.js');m.default.newConversation();m.default.openForCurrentContext();} },
];
return _cmdsCache;
}
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 487e64e..6992953 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -78,8 +78,20 @@
"admin.users_manage_btn": "Manage users",
"admin.users_manage_hint": "Open the user management modal (full CRUD)",
"admin.users_title": "User management",
+ "ai.action_applied": "Applied ✓",
+ "ai.action_apply": "Apply",
+ "ai.action_applying": "Applying…",
+ "ai.action_create_dir": "Create folder {path}",
+ "ai.action_create_file": "Create file {path}",
+ "ai.action_created_dir": "Folder created: {path}",
+ "ai.action_created_file": "File created: {path}",
+ "ai.action_failed": "Action failed: {error}",
"ai.casual": "Casual tone",
+ "ai.close": "Close assistant",
"ai.completion_added": "AI: completion added",
+ "ai.context_documents": "{count} open document(s)",
+ "ai.context_general": "General context",
+ "ai.context_general_desc": "Questions about the app and file creation",
"ai.continue": "Continue",
"ai.custom_rewrite": "Custom rewrite",
"ai.done": "AI: text processed",
@@ -89,6 +101,7 @@
"ai.fix_spelling": "Fix spelling",
"ai.fix_spelling_hint": "Fixes spelling and grammar",
"ai.frontmatter": "Generate frontmatter",
+ "ai.fullscreen": "Fullscreen",
"ai.generate": "Generate",
"ai.hint_add_details": "Adds details",
"ai.hint_edit_selection": "Modifies the selected text",
@@ -96,6 +109,7 @@
"ai.hint_improve": "Improves text quality",
"ai.hint_translate": "Translates the selected text",
"ai.improve": "Improve",
+ "ai.input_hint": "Enter to send · Ctrl+Enter for a line break",
"ai.inline_completion_title": "AI Inline Completion (Ctrl+J) — Auto-complete text",
"ai.invalid_api_key": "AI: invalid API key. Check DEEPSEEK_API_KEY in .env",
"ai.longer": "Longer",
@@ -110,6 +124,7 @@
"ai.model_loading": "Loading…",
"ai.model_offline": "offline",
"ai.model_load_error": "Load error",
+ "ai.model_settings": "Provider & model",
"ai.quota_exceeded": "AI: quota exceeded or payment required",
"ai.rewrite": "💬 Rewrite",
"ai.rewrite_done": "AI: text rewritten",
@@ -121,6 +136,12 @@
"ai.shorter_hint": "Makes the text more concise",
"ai.simplify": "Simplify",
"ai.simplify_hint": "Simplifies the language",
+ "ai.suggestion_capabilities": "What can you do?",
+ "ai.suggestion_create_file": "Create a meeting notes file",
+ "ai.suggestion_docs_contradictions": "Are there contradictions between these documents?",
+ "ai.suggestion_docs_keypoints": "What are the key points of these documents?",
+ "ai.suggestion_docs_summary": "Summarize the open documents",
+ "ai.suggestion_search": "How do I search my notes effectively?",
"ai.summarize": "Summarize",
"ai.text_processed": "AI: text processed ✓",
"ai.text_rewritten": "AI: text rewritten",
@@ -1614,7 +1635,9 @@
"bookslm.context_too_large": "Directory too large — some files were truncated",
"bookslm.source": "Source",
"palette.bookslm_open": "BooksLM: Open for current directory",
+ "palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)",
"palette.bookslm_new": "BooksLM: New conversation",
+ "palette.bookslm_new_desc": "Resets the AI assistant conversation",
"fab.open": "Open AI assistant",
"fab.close": "Close AI assistant",
"mfa.webauthn_title": "Security keys (WebAuthn)",
diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json
index d6df39e..9884e39 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -78,8 +78,20 @@
"admin.users_manage_btn": "Gérer les utilisateurs",
"admin.users_manage_hint": "Ouvrir la modale de gestion des utilisateurs (CRUD complet)",
"admin.users_title": "Gestion utilisateurs",
+ "ai.action_applied": "Appliqué ✓",
+ "ai.action_apply": "Appliquer",
+ "ai.action_applying": "Application…",
+ "ai.action_create_dir": "Créer le dossier {path}",
+ "ai.action_create_file": "Créer le fichier {path}",
+ "ai.action_created_dir": "Dossier créé : {path}",
+ "ai.action_created_file": "Fichier créé : {path}",
+ "ai.action_failed": "Échec de l'action : {error}",
"ai.casual": "Ton décontracté",
+ "ai.close": "Fermer l'assistant",
"ai.completion_added": "AI: complétion ajoutée",
+ "ai.context_documents": "{count} document(s) ouvert(s)",
+ "ai.context_general": "Contexte général",
+ "ai.context_general_desc": "Questions sur l'application et création de fichiers",
"ai.continue": "Continuer",
"ai.custom_rewrite": "Réécriture personnalisée",
"ai.done": "AI: texte traité",
@@ -89,6 +101,7 @@
"ai.fix_spelling": "Corriger l'orthographe",
"ai.fix_spelling_hint": "Corrige les fautes",
"ai.frontmatter": "Générer frontmatter",
+ "ai.fullscreen": "Plein écran",
"ai.generate": "Générer",
"ai.hint_add_details": "Ajoute des détails",
"ai.hint_edit_selection": "Modifie le texte sélectionné",
@@ -96,6 +109,7 @@
"ai.hint_improve": "Améliore la qualité du texte",
"ai.hint_translate": "Traduit le texte sélectionné",
"ai.improve": "Améliorer",
+ "ai.input_hint": "Entrée pour envoyer · Ctrl+Entrée pour un saut de ligne",
"ai.inline_completion_title": "AI Inline Completion (Ctrl+J) — Complète le texte automatiquement",
"ai.invalid_api_key": "AI: clé API invalide. Vérifiez DEEPSEEK_API_KEY dans .env",
"ai.longer": "Allonger",
@@ -110,6 +124,7 @@
"ai.model_loading": "Chargement…",
"ai.model_offline": "hors ligne",
"ai.model_load_error": "Erreur de chargement",
+ "ai.model_settings": "Fournisseur & modèle",
"ai.quota_exceeded": "AI: quota dépassé ou paiement requis",
"ai.rewrite": "💬 Réécrire",
"ai.rewrite_done": "AI: texte réécrit",
@@ -121,6 +136,12 @@
"ai.shorter_hint": "Rend le texte plus concis",
"ai.simplify": "Simplifier",
"ai.simplify_hint": "Simplifie le langage",
+ "ai.suggestion_capabilities": "Que sais-tu faire ?",
+ "ai.suggestion_create_file": "Crée un fichier de notes de réunion",
+ "ai.suggestion_docs_contradictions": "Y a-t-il des contradictions entre ces documents ?",
+ "ai.suggestion_docs_keypoints": "Quels sont les points clés de ces documents ?",
+ "ai.suggestion_docs_summary": "Résume les documents ouverts",
+ "ai.suggestion_search": "Comment rechercher efficacement dans mes notes ?",
"ai.summarize": "Résumer",
"ai.text_processed": "AI: texte traité ✓",
"ai.text_rewritten": "AI: texte réécrit",
@@ -1614,7 +1635,9 @@
"bookslm.context_too_large": "Répertoire trop volumineux — certains fichiers ont été tronqués",
"bookslm.source": "Source",
"palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant",
+ "palette.bookslm_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)",
"palette.bookslm_new": "BooksLM: Nouvelle conversation",
+ "palette.bookslm_new_desc": "Réinitialise la conversation de l'assistant AI",
"fab.open": "Ouvrir l'assistant AI",
"fab.close": "Fermer l'assistant AI",
"mfa.webauthn_title": "Clés de sécurité (WebAuthn)",
diff --git a/frontend/style.css b/frontend/style.css
index 798e985..6f557f0 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -9130,18 +9130,30 @@ body.popup-mode .content-area {
}
.bookslm-btn-toggle:hover { color: var(--accent); background: var(--surface2); }
.bookslm-header { display: flex; align-items: center; gap: 8px;
- padding: 12px 16px; border-bottom: 1px solid var(--border);
- font-size: 14px; font-weight: 600; }
-.bookslm-header .bookslm-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.bookslm-header .bookslm-picker-host,
-.bookslm-header .ai-picker,
-.bookslm-header button { flex-shrink: 0; }
-.bookslm-header .ai-picker { z-index: 1; }
+ padding: 10px 12px; border-bottom: 1px solid var(--border); }
+.bookslm-header .bookslm-context-icon { display: flex; color: var(--accent); flex-shrink: 0; }
+.bookslm-header-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
+.bookslm-header-titles .bookslm-title { font-size: 13px; font-weight: 600;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bookslm-header-titles .bookslm-subtitle { font-size: 11px; color: var(--text-secondary);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bookslm-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.bookslm-header button { background: none; border: none; cursor: pointer;
- color: var(--text-secondary); font-size: 16px; padding: 4px 8px; border-radius: 4px; }
+ color: var(--text-secondary); padding: 5px; border-radius: 6px; display: flex; align-items: center; }
.bookslm-header button:hover { background: var(--surface2); color: var(--text-primary); }
+/* Provider/model picker on its own row so the header actions never get hidden. */
+.bookslm-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+ padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); }
+.bookslm-toolbar-label { font-size: 11px; color: var(--text-secondary);
+ text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
+.bookslm-toolbar .ai-picker { margin-left: 0; padding-left: 0; border-left: none;
+ flex-wrap: wrap; row-gap: 4px; }
+.bookslm-toolbar .ai-picker select { max-width: 200px; }
+.bookslm-panel.fullscreen { width: 100vw; }
.bookslm-status { padding: 6px 16px; font-size: 12px; color: var(--text-secondary);
border-bottom: 1px solid var(--border); display: flex; gap: 12px; align-items: center; }
+.bookslm-status .bookslm-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bookslm-status .bookslm-status-error { color: #f87171; }
.bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px;
background: var(--surface2); overflow: hidden; }
.bookslm-status .bookslm-context-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; }
@@ -9165,6 +9177,20 @@ body.popup-mode .content-area {
background: var(--accent); color: #fff; cursor: pointer; font-size: 14px; }
.bookslm-input-area button:hover { opacity: 0.9; }
.bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
+.bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary);
+ text-align: right; border-top: none; }
+/* Action cards proposed by the General assistant (file/dir creation). */
+.bookslm-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
+.bookslm-action { display: flex; align-items: center; gap: 8px; padding: 8px 10px;
+ border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary); }
+.bookslm-action.done { opacity: 0.65; }
+.bookslm-action-meta { flex: 1; display: flex; align-items: center; gap: 6px; font-size: 12px;
+ color: var(--text-primary); word-break: break-word; }
+.bookslm-action-icon { flex-shrink: 0; }
+.bookslm-action-apply { padding: 5px 12px; border-radius: 6px; border: none;
+ background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; }
+.bookslm-action-apply:disabled { opacity: 0.6; cursor: default; }
+.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
.bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; }
.bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; }
diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs
index dce2229..0233da5 100644
--- a/tests/frontend/ai.test.mjs
+++ b/tests/frontend/ai.test.mjs
@@ -103,6 +103,7 @@ async function main() {
const ai = await import(pathToFileURL(path.join(JS_DIR, "ai.js")).href);
const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href);
const { BooksLM } = bookslmMod;
+ const { collectOpenDocuments, documentsSignature } = bookslmMod;
// ── 1. Rewrite modal resolves with the typed instruction ──
await test("rewrite modal returns the typed instruction on confirm", async () => {
@@ -226,12 +227,146 @@ async function main() {
assert.deepEqual(missingEn, [], `missing EN keys: ${missingEn.join(", ")}`);
});
+ // ── 7. Context detection: open documents across tabs and panes ──
+ await test("collectOpenDocuments merges tabs and panes without duplicates", () => {
+ window.TabManager = {
+ _activeTabId: "t1",
+ _tabs: [
+ { id: "t1", vault: "V", path: "a.md" },
+ { id: "t2", vault: "V", path: "b.md" },
+ ],
+ };
+ window.PaneManager = {
+ tabManagers: {
+ 0: {
+ _activeTabId: "p1",
+ _tabs: [
+ { id: "p1", vault: "V", path: "b.md" },
+ { id: "p2", vault: "W", path: "c.md" },
+ ],
+ },
+ },
+ };
+ const docs = collectOpenDocuments();
+ assert.equal(docs.length, 3, "b.md must be de-duplicated");
+ assert.equal(docs[0].path, "a.md", "active tab first");
+ delete window.TabManager;
+ delete window.PaneManager;
+ });
+
+ await test("documentsSignature is stable", () => {
+ const sig = documentsSignature([{ vault: "V", path: "a.md" }, { vault: "V", path: "b.md" }]);
+ assert.equal(sig, "V/a.md\u0000V/b.md");
+ assert.equal(documentsSignature([]), "");
+ });
+
+ await test("openForCurrentContext picks documents when tabs are open", async () => {
+ window.TabManager = {
+ _activeTabId: "t1",
+ _tabs: [
+ { id: "t1", vault: "V", path: "notes/a.md" },
+ { id: "t2", vault: "V", path: "notes/b.md" },
+ ],
+ };
+ let ctxBody = null;
+ globalThis.fetch = async (url, opts) => {
+ const u = String(url);
+ if (u.includes("/api/ai/status")) {
+ return { ok: true, status: 200, json: async () => ({ configured: true, providers: {} }) };
+ }
+ if (u.includes("/api/ai/bookslm/context")) {
+ ctxBody = JSON.parse(opts.body);
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ files: [{ path: "notes/a.md" }], total_chars: 10, file_count: 1, scope: "documents", max_total_chars: 200000 }),
+ };
+ }
+ return { ok: true, status: 200, json: async () => ({}) };
+ };
+ const b = new BooksLM();
+ await b.openForCurrentContext();
+ assert.equal(b._mode, "documents");
+ assert.equal(ctxBody.mode, "documents");
+ assert.deepEqual(ctxBody.context_files, ["notes/a.md", "notes/b.md"]);
+ await sleep(10);
+ assert.ok(b._panel.classList.contains("open"));
+ b.close();
+ delete window.TabManager;
+ });
+
+ await test("openForCurrentContext falls back to general with no document", async () => {
+ window.TabManager = { _activeTabId: null, _tabs: [] };
+ const b = new BooksLM();
+ await b.openForCurrentContext();
+ assert.equal(b._mode, "general");
+ await sleep(10);
+ assert.ok(b._panel.classList.contains("open"));
+ assert.ok(!b._panel.classList.contains("hidden"), "explicit open must reveal the panel");
+ b.close();
+ delete window.TabManager;
+ });
+
+ // ── 8. Keyboard behaviour: Enter sends, Ctrl+Enter adds a line ──
+ await test("Enter submits and Ctrl+Enter inserts a newline", () => {
+ const b = new BooksLM();
+ b._panel = b._render();
+ document.body.appendChild(b._panel);
+ b._isOpen = true;
+ let sent = 0;
+ b._sendMessage = () => { sent++; };
+
+ const textarea = b._panel.querySelector("textarea");
+ textarea.value = "hello";
+ textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
+ assert.equal(sent, 1, "Enter should submit");
+
+ textarea.value = "line1";
+ textarea.selectionStart = textarea.selectionEnd = 5;
+ textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }));
+ assert.equal(sent, 1, "Ctrl+Enter must not submit");
+ assert.ok(textarea.value.includes("\n"), "Ctrl+Enter should insert a newline");
+ b._panel.remove();
+ });
+
+ // ── 9. General assistant action blocks ──
+ await test("_extractActions parses obsigate-action blocks", () => {
+ const b = new BooksLM();
+ const raw = 'Je crée le fichier.\n```obsigate-action\n{"action":"create_file","vault":"V","path":"a.md","content":"# A"}\n```';
+ const { text, actions } = b._extractActions(raw);
+ assert.equal(actions.length, 1);
+ assert.equal(actions[0].action, "create_file");
+ assert.equal(actions[0].path, "a.md");
+ assert.ok(!text.includes("obsigate-action"), "action block removed from display text");
+ });
+
+ await test("_extractActions ignores malformed blocks", () => {
+ const b = new BooksLM();
+ const { text, actions } = b._extractActions("```obsigate-action\nnot json\n```");
+ assert.equal(actions.length, 0);
+ assert.ok(text.length >= 0);
+ });
+
+ // ── 10. Header ergonomics: actions always visible, picker on its own row ──
+ await test("assistant header exposes context, actions and a picker row", () => {
+ const b = new BooksLM();
+ const panel = b._render();
+ const actions = panel.querySelector(".bookslm-header-actions");
+ assert.ok(actions, "header actions container");
+ assert.ok(actions.querySelector(".bookslm-btn-new"));
+ assert.ok(actions.querySelector(".bookslm-btn-export"));
+ assert.ok(actions.querySelector(".bookslm-btn-fullscreen"));
+ assert.ok(actions.querySelector(".bookslm-btn-close"));
+ assert.ok(panel.querySelector(".bookslm-toolbar .bookslm-picker-host"), "picker on its own toolbar row");
+ assert.ok(panel.querySelector(".bookslm-input-hint"), "keyboard hint shown");
+ panel.remove();
+ });
+
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
- }
-}
+ }}
main().catch((e) => {
console.error(e);
diff --git a/tests/frontend/unit.test.mjs b/tests/frontend/unit.test.mjs
index 9290624..6a8a701 100644
--- a/tests/frontend/unit.test.mjs
+++ b/tests/frontend/unit.test.mjs
@@ -195,7 +195,7 @@ function testAIFabModuleExists() {
const content = readFileSync(AIFAB_PATH, 'utf-8');
assert.ok(content.length > 0, 'ai-fab.js must be a non-empty file');
assert.ok(/from\s+['"]\.\/bookslm\.js['"]/.test(content), 'ai-fab.js must import from ./bookslm.js');
- assert.ok(/from\s+['"]\.\/state\.js['"]/.test(content), 'ai-fab.js must import from ./state.js');
+ assert.ok(/from\s+['"]\.\/utils\.js['"]/.test(content), 'ai-fab.js must import from ./utils.js');
assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'ai-fab.js must import from ./i18n.js');
console.log(' ✓ ai-fab.js exists and imports the expected modules');
}
diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py
index c4a20a8..94eef81 100644
--- a/tests/test_bookslm.py
+++ b/tests/test_bookslm.py
@@ -574,3 +574,175 @@ class TestBooksLMChatEndpoint:
finally:
import shutil
shutil.rmtree(sub, ignore_errors=True)
+
+
+# ── Unit tests: explicit documents context + general prompt ────────────
+
+
+class TestCollectFilesContext:
+ """Tests for collect_files_context() (Documents mode)."""
+
+ def test_collects_only_requested_files(self, tmp_path):
+ from backend.bookslm import collect_files_context
+
+ vault = tmp_path / "vault"
+ (vault / "sub").mkdir(parents=True)
+ (vault / "a.md").write_text("# A", encoding="utf-8")
+ (vault / "b.md").write_text("# B", encoding="utf-8")
+ (vault / "sub" / "c.md").write_text("# C", encoding="utf-8")
+
+ result = collect_files_context(vault, ["a.md", "sub/c.md"], scope="documents")
+
+ assert result["scope"] == "documents"
+ assert result["file_count"] == 2
+ assert [f["path"] for f in result["files"]] == ["a.md", "sub/c.md"]
+ assert result["directory_tree"] == ""
+
+ def test_ignores_paths_outside_vault(self, tmp_path):
+ from backend.bookslm import collect_files_context
+
+ vault = tmp_path / "vault"
+ vault.mkdir()
+ (vault / "ok.md").write_text("# ok", encoding="utf-8")
+ outside = tmp_path / "outside.md"
+ outside.write_text("# secret", encoding="utf-8")
+
+ result = collect_files_context(vault, ["../outside.md", "ok.md"])
+
+ assert result["file_count"] == 1
+ assert result["files"][0]["path"] == "ok.md"
+
+ def test_deduplicates_paths(self, tmp_path):
+ from backend.bookslm import collect_files_context
+
+ vault = tmp_path / "vault"
+ vault.mkdir()
+ (vault / "dup.md").write_text("# dup", encoding="utf-8")
+
+ result = collect_files_context(vault, ["dup.md", "dup.md"])
+
+ assert result["file_count"] == 1
+
+ def test_empty_context(self):
+ from backend.bookslm import empty_context
+
+ ctx = empty_context("general")
+ assert ctx["file_count"] == 0
+ assert ctx["files"] == []
+ assert ctx["scope"] == "general"
+
+
+class TestGeneralPrompt:
+ def test_general_prompt_lists_vaults(self):
+ from backend.bookslm import build_general_system_prompt
+
+ prompt = build_general_system_prompt(["Alpha", "Beta"])
+ assert "Alpha" in prompt
+ assert "Beta" in prompt
+ assert "obsigate-action" in prompt
+
+ def test_documents_prompt_scope(self):
+ from backend.bookslm import build_system_prompt
+
+ context = {
+ "files": [{"path": "a.md", "title": "A", "content": "hello"}],
+ "file_count": 1,
+ "total_chars": 5,
+ "directory_tree": "",
+ }
+ prompt = build_system_prompt(context, scope="documents")
+ assert "Documents ouverts" in prompt
+ assert "hello" in prompt
+
+
+class TestBooksLMContextModes:
+ """Tests for the mode-aware /context endpoint."""
+
+ def test_general_mode_needs_no_vault(self, bookslm_client):
+ token, _ = _login_bookslm(bookslm_client)
+ resp = bookslm_client.post(
+ "/api/ai/bookslm/context",
+ json={"mode": "general"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["scope"] == "general"
+ assert data["file_count"] == 0
+ assert data["files"] == []
+
+ def test_documents_mode_returns_selected_files(self, bookslm_client):
+ token, _ = _login_bookslm(bookslm_client)
+ resp = bookslm_client.post(
+ "/api/ai/bookslm/context",
+ json={
+ "mode": "documents",
+ "vault": "TestVault",
+ "context_files": ["notes/note1.md"],
+ },
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["scope"] == "documents"
+ assert data["file_count"] == 1
+ assert data["files"][0]["path"] == "notes/note1.md"
+
+ def test_documents_mode_without_files_degrades_to_general(self, bookslm_client):
+ token, _ = _login_bookslm(bookslm_client)
+ resp = bookslm_client.post(
+ "/api/ai/bookslm/context",
+ json={"mode": "documents", "vault": "TestVault", "context_files": []},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["scope"] == "general"
+ assert data["file_count"] == 0
+
+ def test_documents_mode_requires_vault(self, bookslm_client):
+ token, _ = _login_bookslm(bookslm_client)
+ resp = bookslm_client.post(
+ "/api/ai/bookslm/context",
+ json={"mode": "documents", "context_files": ["notes/note1.md"]},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert resp.status_code == 400
+
+ def test_unknown_mode_falls_back_to_directory(self, bookslm_client):
+ token, _ = _login_bookslm(bookslm_client)
+ resp = bookslm_client.post(
+ "/api/ai/bookslm/context",
+ json={"mode": "bogus", "vault": "TestVault", "directory": "notes"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["scope"] == "directory"
+
+
+class TestBooksLMChatModes:
+ def test_general_chat_does_not_require_vault(self, bookslm_client):
+ """General mode must not 400/404 on a missing vault (only 500 if no key)."""
+ token, _ = _login_bookslm(bookslm_client)
+ resp = bookslm_client.post(
+ "/api/ai/bookslm/chat",
+ json={"mode": "general", "message": "Bonjour"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
+ assert resp.status_code != 422
+
+ def test_documents_chat_accepts_context_files(self, bookslm_client):
+ token, _ = _login_bookslm(bookslm_client)
+ resp = bookslm_client.post(
+ "/api/ai/bookslm/chat",
+ json={
+ "mode": "documents",
+ "vault": "TestVault",
+ "context_files": ["notes/note1.md"],
+ "message": "Résume",
+ },
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
+ assert resp.status_code != 422