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