CI / lint (push) Successful in 41s
CI / security (push) Successful in 28s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m5s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Trois bugs corrigés + une amélioration demandée :
1. **fix(admin): /admin.html redirigeait toujours vers /**
- admin.js _gateAdmin() lisait /api/auth/status qui ne contient PAS le rôle user
- Remplacé par /api/auth/me (retourne username, role, vaults)
- Le code distingue maintenant le cas 'auth désactivé' (admin anonyme) du
cas 'auth requise non admin' (affiche écran forbidden)
2. **fix(ai): les modèles Nvidia/Xiaomi ne se chargeaient pas dans les dropdowns**
- Xiaomi : l'endpoint public /v1/models est instable, échec réseau fréquent
- Toutes les erreurs réseau/d'API renvoyaient models=[] → dropdown vide
- Ajout d'un fallback curé : _FALLBACK_MODELS dict avec 4-8 modèles populaires
par provider, TOUJOURS retourné si la clé manque OU si l'API distante échoue
- Le frontend voit désormais 'fallback' vs 'live' comme hint pour l'utilisateur
- Gemini parsing : strip du préfixe 'models/' retourné par l'API Gemini
- 7 nouveaux tests pytest pour _FALLBACK_MODELS + endpoint /api/config/ai-models
3. **feat(ai): picker provider/model dans la toolbar AI + BooksLM**
- Plusieurs providers peuvent maintenant être activés simultanément
- Sélection provider+model par section (Forge, BooksLM, etc.) via dropdown
- État persisté en localStorage (le choix suit l'utilisateur entre sections)
- Chaque appel AI passe maintenant {provider, model} au backend
- ai.js : aiAction() lit le picker et l'injecte dans le body
- bookslm.js : envoie provider+model à /api/ai/bookslm/chat
- ai_routes.py + bookslm_routes.py : AIRequest et BooksLMChatRequest
acceptent provider+model, avec save/restore du modèle original
pour ne pas affecter les autres requêtes concurrentes
- 7 nouvelles clés i18n (ai.provider, ai.model, ai.model_loading, etc.)
- 1 nouveau test bookslm vérifie que le schema accepte provider+model
- validate-imports.mjs : fix faux positif sur 'export { X as Y }'
Vérifié :
- pytest : 502 passed, 5 skipped (494 baseline + 7 AI models + 1 BooksLM)
- frontend unit : 7 passed
- validate-imports : 30 modules / 204 exports / 0 erreur
- pane-manager JSDOM : 9/9
- ruff check backend/ : All checks passed
173 lines
6.6 KiB
Python
173 lines
6.6 KiB
Python
"""BooksLM API routes — directory-scoped AI chat for Obsidian vaults."""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
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
|
|
|
|
logger = logging.getLogger("obsigate.bookslm_routes")
|
|
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
|
|
|
|
|
|
# ── Request models ──
|
|
|
|
|
|
class BooksLMContextRequest(BaseModel):
|
|
vault: str = Field(description="Vault name")
|
|
directory: str = Field(default="", description="Relative directory path within the vault")
|
|
|
|
|
|
class BooksLMChatRequest(BaseModel):
|
|
vault: str = Field(description="Vault name")
|
|
directory: str = Field(default="", description="Relative directory path within the vault")
|
|
message: str = Field(description="User message")
|
|
conversation_history: list[dict[str, str]] = Field(
|
|
default_factory=list,
|
|
description="Previous conversation turns [{role, content}]",
|
|
)
|
|
provider: str | None = Field(
|
|
default=None,
|
|
description="AI provider override (e.g. 'deepseek', 'openrouter', 'gemini', 'nvidia', 'xiaomi', 'mistral', 'qwencloud'). "
|
|
"If not set, uses DEFAULT_PROVIDER.",
|
|
)
|
|
model: str | None = Field(
|
|
default=None,
|
|
description="Model name to use for this request. If not set, uses the provider's default model.",
|
|
)
|
|
|
|
|
|
# ── Endpoints ──
|
|
|
|
|
|
@router.post("/context")
|
|
async def api_bookslm_context(
|
|
req: BooksLMContextRequest,
|
|
current_user=Depends(require_auth),
|
|
):
|
|
"""Collect directory context for BooksLM.
|
|
|
|
Returns file list, content, and metadata for the specified directory.
|
|
"""
|
|
if not check_vault_access(req.vault, current_user):
|
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
|
|
|
|
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
|
|
|
|
|
|
@router.post("/chat")
|
|
async def api_bookslm_chat(
|
|
req: BooksLMChatRequest,
|
|
current_user=Depends(require_auth),
|
|
):
|
|
"""Chat with AI about directory contents (BooksLM).
|
|
|
|
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.
|
|
"""
|
|
if not check_vault_access(req.vault, current_user):
|
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
|
|
|
|
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"])
|
|
|
|
# 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")
|
|
|
|
# Build system prompt
|
|
system_prompt = build_system_prompt(context)
|
|
|
|
# Call AI provider
|
|
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini
|
|
|
|
# Build messages with conversation history
|
|
messages_text = ""
|
|
if req.conversation_history:
|
|
for turn in req.conversation_history:
|
|
role = turn.get("role", "user")
|
|
content = turn.get("content", "")
|
|
if role == "user":
|
|
messages_text += f"\n\nUtilisateur : {content}"
|
|
elif role == "assistant":
|
|
messages_text += f"\n\nAssistant : {content}"
|
|
|
|
# Current message
|
|
user_prompt = req.message
|
|
if messages_text:
|
|
user_prompt = f"Historique de la conversation :{messages_text}\n\nQuestion actuelle : {req.message}"
|
|
|
|
async def generate_sse():
|
|
try:
|
|
# Resolve provider: explicit override wins, else default.
|
|
# Fall back to first available if the requested one isn't configured.
|
|
cfg_name = (req.provider or DEFAULT_PROVIDER).lower()
|
|
if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"):
|
|
# Try next available provider
|
|
for pname, pcfg in PROVIDERS.items():
|
|
if pcfg.get("api_key") and pname != "gemini":
|
|
cfg_name = pname
|
|
break
|
|
else:
|
|
# No provider available at all
|
|
err = "Aucun fournisseur AI configuré (clés API manquantes)"
|
|
error_data = json.dumps({"error": err}, ensure_ascii=False)
|
|
yield f"event: error\ndata: {error_data}\n\n"
|
|
return
|
|
|
|
# Optional per-request model override
|
|
original_model = None
|
|
if req.model and cfg_name in PROVIDERS:
|
|
original_model = PROVIDERS[cfg_name].get("model")
|
|
PROVIDERS[cfg_name]["model"] = req.model
|
|
try:
|
|
if cfg_name == "gemini":
|
|
response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096)
|
|
else:
|
|
response = await _call_deepseek_openrouter(
|
|
user_prompt, system_prompt,
|
|
provider=cfg_name,
|
|
temperature=0.3,
|
|
max_tokens=4096,
|
|
)
|
|
finally:
|
|
# Restore the original model so other calls aren't affected
|
|
if original_model is not None and cfg_name in PROVIDERS:
|
|
PROVIDERS[cfg_name]["model"] = original_model
|
|
|
|
# Send the full response as a single SSE event
|
|
data = json.dumps({"token": response, "provider": cfg_name, "model": req.model or PROVIDERS.get(cfg_name, {}).get("model", "")}, ensure_ascii=False)
|
|
yield f"event: message\ndata: {data}\n\n"
|
|
yield "event: done\ndata: {}\n\n"
|
|
except Exception as e:
|
|
logger.error(f"BooksLM chat error: {e}")
|
|
error_data = json.dumps({"error": str(e)}, ensure_ascii=False)
|
|
yield f"event: error\ndata: {error_data}\n\n"
|
|
|
|
return StreamingResponse(
|
|
generate_sse(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|