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": "
')
- // Inline code
.replace(/`([^`]+)`/g, '$2$1')
- // Bold
.replace(/\*\*(.+?)\*\*/g, '$1')
- // Italic
.replace(/\*(.+?)\*/g, '$1')
- // Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
- // Unordered lists
.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