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:
+81
-30
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user