feat(ai): assistant AI multi-contexte + ergonomie sidebar et clavier
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

Contexte:
- Repertoire (mode historique BooksLM, via menu contextuel dossier)
- Documents (bouton flottant quand des documents sont ouverts)
- General (bouton flottant sans document: aide app + creation de fichiers)
- Le contexte est affiche dans le header; suggestions adaptees au mode
- Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto)

Backend:
- /api/ai/bookslm/context et /chat acceptent mode + context_files
- collect_files_context(), empty_context(), build_general_system_prompt()
- mode documents degrade vers general si aucun fichier lisible

Corrige:
- bouton flottant passait le chemin d'un fichier comme repertoire -> erreur
  'Aucun fichier markdown trouve dans ce dossier'
- panneau precedent masque (localStorage) ne se rouvrait plus
- selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles)
- Entree envoie, Ctrl+Entree insere un saut de ligne

Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
This commit is contained in:
2026-09-11 09:11:00 -04:00
parent 62023acd4d
commit d6fdcef4d4
14 changed files with 1049 additions and 214 deletions
+158 -8
View File
@@ -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": "<nom du vault>", "path": "<chemin/relatif.md>", "content": "<contenu markdown>"}
```
Pour créer un dossier :
```obsigate-action
{"action": "create_directory", "vault": "<nom du vault>", "path": "<chemin/relatif>"}
```
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.
+81 -30
View File
@@ -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
+1
View File
@@ -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'")