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
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:
+158
-8
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user