feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:06:40 -04:00
parent c55e3e0cbc
commit 4c4e415975
27 changed files with 1465 additions and 386 deletions
+13
View File
@@ -28,6 +28,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
fallback automatique en chat simple si le provider rejette les tools. Tests : `tests/test_agent_loop.py`, fallback automatique en chat simple si le provider rejette les tools. Tests : `tests/test_agent_loop.py`,
`tests/test_ai_chat.py`, `tests/test_bookslm.py`. `tests/test_ai_chat.py`, `tests/test_bookslm.py`.
- **#79 A2/B4/B5 — services partagés, SSE streaming & confirmations UI** — extraction de la logique
lecture/recherche dans `backend/services/` (`errors.py`, `paths.py`, `vaults.py`, `files.py`,
`search.py`) : routes REST et outils IA consomment la même source ; `ServiceError` mappée vers
`HTTPException` (routes) et `ToolError` (outils). `/api/ai/bookslm/chat` **streame réellement** les
tokens (`stream_completion`, OpenAI-compat + Gemini ; GZip ignoré pour les SSE BooksLM). Mode agent
côté UI (toggle, événements `tool`/`confirmation`, carte Apply + aperçu diff LCS) et reprise
`confirm`/`confirm_messages` de l'agent loop (confirmation one-shot). Tests : `tests/test_services.py`,
`tests/test_ai_chat.py`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`.
- **#79 G — Sélection fournisseur/modèle par défaut** — `ai_default_provider` + `ai_default_models` - **#79 G — Sélection fournisseur/modèle par défaut** — `ai_default_provider` + `ai_default_models`
persistés dans `data/config.json`, rechargement à chaud dans `backend/ai.py`, sélecteurs persistés dans `data/config.json`, rechargement à chaud dans `backend/ai.py`, sélecteurs
« Fournisseur par défaut » / « Modèle par défaut » dans `#cfg-ai`, i18n FR/EN. « Fournisseur par défaut » / « Modèle par défaut » dans `#cfg-ai`, i18n FR/EN.
@@ -46,6 +55,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
renderer bloc/inline complet ; l'en-tête expose un bouton « Historique des sessions » ; les badges renderer bloc/inline complet ; l'en-tête expose un bouton « Historique des sessions » ; les badges
de sources ouvrent désormais réellement le fichier (événement `obsigate:open-file`). de sources ouvrent désormais réellement le fichier (événement `obsigate:open-file`).
- **Assistant IA (`frontend/js/bookslm.js`)** — bouton « mode agent » dans l'en-tête (persisté en
`localStorage`) : l'envoi bascule vers `/api/ai/bookslm/agent`, affiche la trace des appels d'outils
et les cartes de confirmation (Apply + aperçu diff) ; `/chat` reste le défaut. i18n FR/EN.
### Corrigé ### Corrigé
- **Typage backend (mypy) — 33 erreurs corrigées** (`backend/main.py`, `indexer.py`, - **Typage backend (mypy) — 33 erreurs corrigées** (`backend/main.py`, `indexer.py`,
+67 -2
View File
@@ -98,6 +98,59 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
} }
def _execute_confirmed(
ctx: ToolContext,
confirm_pending: dict[str, Any],
convo: list[dict[str, Any]],
executed: list[ToolCallRecord],
on_tool_call: Callable[[ToolCallRecord], None] | None,
) -> None:
"""Apply a previously-paused mutating tool call and feed its result back.
The pending payload is the ``error`` object emitted by a ``confirmation``
event. The assistant tool-call message is expected to already be in
``convo`` (it is part of the snapshot returned with the confirmation).
"""
from backend.ai_chat import ToolCall
error = confirm_pending.get("error", confirm_pending)
name = error.get("tool")
arguments = error.get("arguments") or {}
call_id = error.get("id") or "call_pending"
if not name:
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
# Make sure the assistant tool-call message is present in the snapshot.
if not any(
m.get("role") == "assistant" and any(
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
)
for m in convo
):
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
try:
result = call_tool(name, ctx, arguments, confirm=True)
payload = result.data
ok = True
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(name=name, arguments=arguments, ok=ok, result=payload)
executed.append(record)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
async def run_agent( async def run_agent(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
*, *,
@@ -106,6 +159,8 @@ async def run_agent(
tools: list[dict[str, Any]] | None = None, tools: list[dict[str, Any]] | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS, max_iterations: int = DEFAULT_MAX_ITERATIONS,
on_tool_call: Callable[[ToolCallRecord], None] | None = None, on_tool_call: Callable[[ToolCallRecord], None] | None = None,
resume_messages: list[dict[str, Any]] | None = None,
confirm_pending: dict[str, Any] | None = None,
) -> AgentResult: ) -> AgentResult:
"""Run the tool-calling loop until completion. """Run the tool-calling loop until completion.
@@ -119,6 +174,10 @@ async def run_agent(
pass ``[]`` to disable tool calling (plain chat). pass ``[]`` to disable tool calling (plain chat).
max_iterations: Hard cap on LLM round-trips. max_iterations: Hard cap on LLM round-trips.
on_tool_call: Optional callback invoked after each executed tool call. on_tool_call: Optional callback invoked after each executed tool call.
resume_messages: Conversation snapshot from a paused run (returned with
a ``confirmation`` event). When set, the loop resumes from it.
confirm_pending: Pending mutating tool call to apply before resuming
(two-step propose/apply).
Returns: Returns:
An :class:`AgentResult`. ``stopped`` is ``done``, ``max_iterations`` or An :class:`AgentResult`. ``stopped`` is ``done``, ``max_iterations`` or
@@ -129,9 +188,12 @@ async def run_agent(
if tools is None: if tools is None:
tools = get_tool_schemas(scope=ToolScope.IN_APP) tools = get_tool_schemas(scope=ToolScope.IN_APP)
convo = [dict(m) for m in messages] convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
executed: list[ToolCallRecord] = [] executed: list[ToolCallRecord] = []
if confirm_pending:
_execute_confirmed(ctx, confirm_pending, convo, executed, on_tool_call)
for iteration in range(1, max_iterations + 1): for iteration in range(1, max_iterations + 1):
response = await llm(convo, tools) response = await llm(convo, tools)
@@ -153,13 +215,16 @@ async def run_agent(
ok = True ok = True
except ToolConfirmationRequired as e: except ToolConfirmationRequired as e:
logger.info(f"Agent paused: confirmation required for '{call.name}'") logger.info(f"Agent paused: confirmation required for '{call.name}'")
pending = e.to_dict()
# Include the tool-call id so the client can echo it back.
pending["error"]["id"] = call.id
return AgentResult( return AgentResult(
content=response.content or "", content=response.content or "",
messages=convo, messages=convo,
tool_calls=executed, tool_calls=executed,
iterations=iteration, iterations=iteration,
stopped=STOP_CONFIRMATION_REQUIRED, stopped=STOP_CONFIRMATION_REQUIRED,
pending=e.to_dict(), pending=pending,
) )
except ToolError as e: except ToolError as e:
payload = e.to_dict() payload = e.to_dict()
+106
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import json import json
import logging import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -232,3 +233,108 @@ async def _post_json(url: str, headers: dict[str, str] | None, payload: dict[str
resp = await client.post(url, headers=headers or {}, json=payload) resp = await client.post(url, headers=headers or {}, json=payload)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
# ── Streaming (SSE token stream) ────────────────────────────────────────
async def stream_completion(
messages: list[dict[str, Any]],
*,
provider: str | None = None,
model: str | None = None,
temperature: float = 0.3,
max_tokens: int = 4096,
) -> AsyncIterator[str]:
"""Yield content deltas from a chat completion as they arrive.
Only text content is streamed (no tool calling): this backs the plain
``/api/ai/bookslm/chat`` endpoint. The tool-calling ``/agent`` endpoint
keeps using :func:`chat_completion` because tool calls need the complete
response before they can be executed.
"""
cfg = _get_provider_config(provider) # type: ignore[arg-type]
resolved_model = model or cfg["model"]
if cfg["name"] == "gemini":
stream = _gemini_stream(messages, resolved_model, temperature, max_tokens)
else:
stream = _openai_stream(messages, cfg, resolved_model, temperature, max_tokens)
async for chunk in stream:
yield chunk
async def _openai_stream(
messages: list[dict[str, Any]],
cfg: dict[str, Any],
model: str,
temperature: float,
max_tokens: int,
) -> AsyncIterator[str]:
"""Stream an OpenAI-compatible ``/chat/completions`` response."""
headers = _build_headers(cfg)
url = f"{cfg['base_url']}/chat/completions"
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", url, headers=headers, json=payload) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line or not line.startswith("data:"):
continue
raw = line[5:].strip()
if raw == "[DONE]":
break
try:
chunk = json.loads(raw)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
async def _gemini_stream(
messages: list[dict[str, Any]],
model: str,
temperature: float,
max_tokens: int,
) -> AsyncIterator[str]:
"""Stream Gemini's ``streamGenerateContent`` response (SSE)."""
cfg = PROVIDERS["gemini"]
system, contents = _gemini_contents(messages)
payload: dict[str, Any] = {
"contents": contents,
"generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens},
}
if system:
payload["system_instruction"] = {"parts": [{"text": system}]}
url = f"{cfg['base_url']}/models/{model}:streamGenerateContent?alt=sse&key={cfg['api_key']}"
async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", url, json=payload) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line or not line.startswith("data:"):
continue
raw = line[5:].strip()
if not raw:
continue
try:
chunk = json.loads(raw)
except json.JSONDecodeError:
continue
for candidate in chunk.get("candidates") or []:
for part in candidate.get("content", {}).get("parts", []):
text = part.get("text")
if text:
yield text
+53 -60
View File
@@ -3,13 +3,14 @@
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from backend.agent.loop import run_agent from backend.agent.loop import run_agent
from backend.ai_chat import chat_completion from backend.ai_chat import chat_completion, stream_completion
from backend.auth.middleware import check_vault_access, require_auth from backend.auth.middleware import check_vault_access, require_auth
from backend.bookslm import ( from backend.bookslm import (
build_general_system_prompt, build_general_system_prompt,
@@ -63,6 +64,16 @@ class BooksLMChatRequest(BaseModel):
default=None, default=None,
description="Model name to use for this request. If not set, uses the provider's default model.", description="Model name to use for this request. If not set, uses the provider's default model.",
) )
confirm: dict[str, Any] | None = Field(
default=None,
description="Pending tool confirmation to apply (two-step propose/apply). "
"Shape: the ``error`` object of a previous ``confirmation`` event.",
)
confirm_messages: list[dict[str, Any]] | None = Field(
default=None,
description="Conversation snapshot returned alongside a ``confirmation`` event, "
"echoed back to resume the agent run.",
)
def _normalize_mode(mode: str | None) -> str: def _normalize_mode(mode: str | None) -> str:
@@ -169,66 +180,37 @@ async def api_bookslm_chat(
""" """
system_prompt = _resolve_system_prompt(req, current_user) system_prompt = _resolve_system_prompt(req, current_user)
# Call AI provider messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini for turn in req.conversation_history:
role = turn.get("role")
# Build messages with conversation history content = turn.get("content", "")
messages_text = "" if role in ("user", "assistant") and content:
if req.conversation_history: messages.append({"role": role, "content": content})
for turn in req.conversation_history: messages.append({"role": "user", "content": req.message})
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(): async def generate_sse():
try: try:
# Resolve provider: explicit override wins, else default. # Resolve provider: explicit override wins, else first available.
# Fall back to first available if the requested one isn't configured. cfg_name = _resolve_provider_name(req.provider)
cfg_name = (req.provider or DEFAULT_PROVIDER).lower() if cfg_name is None:
if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"): err = "Aucun fournisseur AI configuré (clés API manquantes)"
# Try next available provider error_data = json.dumps({"error": err}, ensure_ascii=False)
for pname, pcfg in PROVIDERS.items(): yield f"event: error\ndata: {error_data}\n\n"
if pcfg.get("api_key") and pname != "gemini": return
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 # Stream token deltas as they arrive from the provider.
original_model = None async for token in stream_completion(
if req.model and cfg_name in PROVIDERS: messages,
original_model = PROVIDERS[cfg_name].get("model") provider=cfg_name,
PROVIDERS[cfg_name]["model"] = req.model model=req.model,
try: temperature=0.3,
if cfg_name == "gemini": max_tokens=4096,
response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096) ):
else: data = json.dumps(
response = await _call_deepseek_openrouter( {"token": token, "provider": cfg_name, "model": req.model or ""},
user_prompt, system_prompt, ensure_ascii=False,
provider=cfg_name, )
temperature=0.3, yield f"event: message\ndata: {data}\n\n"
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" yield "event: done\ndata: {}\n\n"
except Exception as e: except Exception as e:
logger.error(f"BooksLM chat error: {e}") logger.error(f"BooksLM chat error: {e}")
@@ -260,7 +242,9 @@ async def api_bookslm_agent(
Same context as ``/chat`` but the model may call tools (read/search the Same context as ``/chat`` but the model may call tools (read/search the
vault) through the shared tool layer. Emits one ``tool`` event per executed vault) through the shared tool layer. Emits one ``tool`` event per executed
tool call, then a final ``message`` event. Mutating tools pause the run with tool call, then a final ``message`` event. Mutating tools pause the run with
a ``confirmation`` event (two-step propose/apply). a ``confirmation`` event (two-step propose/apply) carrying the pending call
and the conversation snapshot; the client resumes by echoing them back in
``confirm`` / ``confirm_messages``.
""" """
system_prompt = _resolve_system_prompt(req, current_user) system_prompt = _resolve_system_prompt(req, current_user)
@@ -295,7 +279,13 @@ async def api_bookslm_agent(
yield f"event: error\ndata: {error_data}\n\n" yield f"event: error\ndata: {error_data}\n\n"
return return
result = await run_agent(messages, ctx=ctx, llm=_llm) result = await run_agent(
messages,
ctx=ctx,
llm=_llm,
resume_messages=req.confirm_messages,
confirm_pending=req.confirm,
)
for rec in result.tool_calls: for rec in result.tool_calls:
payload = json.dumps( payload = json.dumps(
@@ -305,7 +295,10 @@ async def api_bookslm_agent(
yield f"event: tool\ndata: {payload}\n\n" yield f"event: tool\ndata: {payload}\n\n"
if result.stopped == "confirmation_required": if result.stopped == "confirmation_required":
pending = json.dumps(result.pending or {}, ensure_ascii=False) pending = json.dumps(
{"pending": result.pending or {}, "messages": result.messages},
ensure_ascii=False,
)
yield f"event: confirmation\ndata: {pending}\n\n" yield f"event: confirmation\ndata: {pending}\n\n"
else: else:
data = json.dumps( data = json.dumps(
+37 -136
View File
@@ -20,7 +20,7 @@ from typing import Any
import frontmatter import frontmatter
import mistune import mistune
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@@ -99,12 +99,15 @@ from backend.schemas import (
) )
from backend.search import ( from backend.search import (
advanced_search, advanced_search,
get_all_tags,
init_inverted_index, init_inverted_index,
search,
suggest_tags, suggest_tags,
suggest_titles, suggest_titles,
) )
from backend.services.errors import ServiceError
from backend.services.files import read_raw_file
from backend.services.search import list_tags as service_list_tags
from backend.services.search import search_vaults
from backend.services.vaults import browse_directory, list_accessible_vaults
from backend.vault_settings import ( from backend.vault_settings import (
get_vault_setting, get_vault_setting,
update_vault_setting, update_vault_setting,
@@ -737,6 +740,12 @@ def _custom_openapi():
app.openapi = _custom_openapi # type: ignore[method-assign] app.openapi = _custom_openapi # type: ignore[method-assign]
@app.exception_handler(ServiceError)
async def _service_error_handler(request: Request, exc: ServiceError):
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``)."""
return JSONResponse(status_code=exc.status, content={"detail": exc.message})
# GZip compression — reduces bandwidth by ~70% for text responses # GZip compression — reduces bandwidth by ~70% for text responses
# Custom wrapper: skip compression for SSE streams (/api/events) # Custom wrapper: skip compression for SSE streams (/api/events)
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
@@ -749,8 +758,16 @@ class SSESafeGZipMiddleware(GZipMiddleware):
GZip buffering breaks incremental streaming required by SSE. GZip buffering breaks incremental streaming required by SSE.
We detect SSE endpoints by path and bypass compression entirely. We detect SSE endpoints by path and bypass compression entirely.
""" """
# SSE endpoints that must not be buffered by GZip.
_SSE_PATHS = (
"/api/events",
"/api/admin/stream",
"/api/ai/bookslm/chat",
"/api/ai/bookslm/agent",
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope.get("path") in ("/api/events", "/api/admin/stream"): if scope["type"] == "http" and scope.get("path") in self._SSE_PATHS:
# Bypass GZip: passthrough directly to the inner app # Bypass GZip: passthrough directly to the inner app
await self.app(scope, receive, send) await self.app(scope, receive, send)
else: else:
@@ -864,11 +881,13 @@ def _content_disposition(disposition: str, filename: str) -> str:
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}" return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path: def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a relative path safely within the vault root. """Resolve a relative path safely within the vault root.
Prevents directory traversal attacks by ensuring the resolved Thin wrapper around the shared :func:`backend.services.paths.resolve_safe_path`
absolute path is a descendant of *vault_root*. (single implementation used by both routes and tools). The raised
:class:`ServiceError` is mapped to an ``HTTPException`` response by the
global exception handler in this module.
Args: Args:
vault_root: The vault's root directory (absolute). vault_root: The vault's root directory (absolute).
@@ -876,34 +895,10 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
Returns: Returns:
Resolved absolute ``Path``. Resolved absolute ``Path``.
Raises:
HTTPException(403): When the resolved path escapes the vault root.
""" """
# Construct the full path without resolving symlinks first from backend.services.paths import resolve_safe_path as _service_resolve
full_path = vault_root / relative_path
return _service_resolve(vault_root, relative_path)
# Resolve both paths to handle symlinks
try:
resolved = full_path.resolve(strict=False)
vault_resolved = vault_root.resolve(strict=False)
except Exception as e:
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
raise HTTPException(status_code=500, detail=f"Path resolution error: {e!s}")
# Check if resolved path is within vault using string comparison (case-insensitive on Windows)
try:
# This will raise ValueError if resolved is not relative to vault_resolved
resolved.relative_to(vault_resolved)
except ValueError:
# Try case-insensitive comparison for Windows/Docker compatibility
resolved_str = str(resolved).lower()
vault_str = str(vault_resolved).lower()
if not resolved_str.startswith(vault_str):
logger.warning(f"Path outside vault - vault: {vault_resolved}, requested: {relative_path}, resolved: {resolved}")
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
return resolved
def _backup_file(file_path: Path, vault_name: str, relative_path: str): def _backup_file(file_path: Path, vault_name: str, relative_path: str):
@@ -1271,18 +1266,7 @@ async def api_vaults(current_user=Depends(require_auth)):
Returns: Returns:
List of vault summary objects filtered by user permissions. List of vault summary objects filtered by user permissions.
""" """
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) return list_accessible_vaults(current_user)
result = []
for name, data in index.items():
if "*" in user_vaults or name in user_vaults:
v_type = data.get("config", {}).get("type", "VAULT")
result.append({
"name": name,
"file_count": len(data["files"]),
"tag_count": len(data["tags"]),
"type": v_type,
})
return result
def humanize_mtime(mtime: float) -> str: def humanize_mtime(mtime: float) -> str:
@@ -1516,57 +1500,7 @@ async def api_browse(vault_name: str, path: str = "", current_user=Depends(requi
""" """
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) return browse_directory(vault_name, path)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Path traversal protection
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
# Get vault settings for hideHiddenFiles
settings = get_vault_setting(vault_name) or {}
hide_hidden = settings.get("hideHiddenFiles", False)
items = []
try:
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
# Skip hidden files/dirs if the setting is enabled
if hide_hidden and entry.name.startswith("."):
continue
rel = str(entry.relative_to(vault_root)).replace("\\", "/")
if entry.is_dir():
# Count only direct children (files and subdirs) for performance
try:
file_count = sum(
1 for child in entry.iterdir()
if (not hide_hidden or not child.name.startswith("."))
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
or child.is_dir())
)
except PermissionError:
file_count = 0
items.append({
"name": entry.name,
"path": rel,
"type": "directory",
"children_count": file_count,
})
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
items.append({
"name": entry.name,
"path": rel,
"type": "file",
"size": entry.stat().st_size,
"extension": entry.suffix.lower(),
})
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
return {"vault": vault_name, "path": path, "items": items}
# Map file extensions to highlight.js language hints # Map file extensions to highlight.js language hints
@@ -1599,33 +1533,7 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
""" """
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) return read_raw_file(vault_name, path)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
logger.error(f"Permission denied reading raw file {path}: {e}")
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
except UnicodeDecodeError:
# Binary file - try to read as binary and decode with errors='replace'
try:
raw = file_path.read_bytes().decode("utf-8", errors="replace")
except Exception as e:
logger.error(f"Error reading binary raw file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Cannot read file: {e!s}")
except Exception as e:
logger.error(f"Unexpected error reading raw file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
return {"vault": vault_name, "path": path, "raw": raw}
@app.get("/api/file/{vault_name}/download", response_class=FileResponse) @app.get("/api/file/{vault_name}/download", response_class=FileResponse)
@@ -3120,18 +3028,12 @@ async def api_search(
``SearchResponse`` with ranked results and snippets. ``SearchResponse`` with ranked results and snippets.
""" """
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Fetch full result set (capped at DEFAULT_SEARCH_LIMIT internally) # Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and
all_results = await loop.run_in_executor( # paginate in the shared service so routes and tools share the same logic.
return await loop.run_in_executor(
_search_executor, _search_executor,
partial(search, q, vault_filter=vault, tag_filter=tag), partial(search_vaults, q, vault, tag, limit, offset),
) )
total = len(all_results)
page = all_results[offset: offset + limit]
return {
"query": q, "vault_filter": vault, "tag_filter": tag,
"count": len(page), "total": total, "offset": offset, "limit": limit,
"results": page,
}
@app.get("/api/tags", response_model=TagsResponse) @app.get("/api/tags", response_model=TagsResponse)
@@ -3144,8 +3046,7 @@ async def api_tags(vault: str | None = Query(None, description="Vault filter"),
Returns: Returns:
``TagsResponse`` with tags sorted by descending count. ``TagsResponse`` with tags sorted by descending count.
""" """
tags = get_all_tags(vault_filter=vault) return {"vault_filter": vault, "tags": service_list_tags(vault)}
return {"vault_filter": vault, "tags": tags}
@app.get("/api/tree-search", response_model=TreeSearchResponse) @app.get("/api/tree-search", response_model=TreeSearchResponse)
+29
View File
@@ -0,0 +1,29 @@
"""Domain errors shared by the reusable service layer.
Services are transport-agnostic: they raise :class:`ServiceError` carrying a
stable ``code`` and an HTTP ``status`` hint. The REST layer maps it to an
``HTTPException`` (via the global handler in ``backend.main``) while the tool
layer maps it to a :class:`backend.tools.context.ToolError`.
"""
from __future__ import annotations
from typing import Any
class ServiceError(Exception):
"""Base error for the shared business-logic services."""
def __init__(
self,
message: str,
*,
code: str = "service_error",
status: int = 400,
details: dict[str, Any] | None = None,
):
super().__init__(message)
self.message = message
self.code = code
self.status = status
self.details = details or {}
+86
View File
@@ -0,0 +1,86 @@
"""File reading services shared by REST routes and the AI tool layer."""
from __future__ import annotations
import logging
from typing import Any
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
logger = logging.getLogger("obsigate.services.files")
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
"""Return the raw text content of a vault file (no redaction)."""
root = get_vault_root(vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
logger.error(f"Permission denied reading raw file {path}: {e}")
raise ServiceError(f"Permission denied: cannot read file {path}", code="permission_denied", status=403) from e
except UnicodeDecodeError:
try:
raw = file_path.read_bytes().decode("utf-8", errors="replace")
except Exception as e:
logger.error(f"Error reading binary raw file {path}: {e}")
raise ServiceError(f"Cannot read file: {e!s}", code="read_error", status=500) from e
except Exception as e:
logger.error(f"Unexpected error reading raw file {path}: {e}")
raise ServiceError(f"Error reading file: {e!s}", code="read_error", status=500) from e
return {"vault": vault_name, "path": path, "raw": raw}
def read_file_text(
vault_name: str,
path: str,
*,
redact: bool = True,
max_bytes: int | None = None,
) -> dict[str, Any]:
"""Return a vault file's text content, optionally redacted and size-capped.
Raises:
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
error (500).
"""
root = get_vault_root(vault_name)
target = resolve_safe_path(root, path)
if not target.exists() or not target.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
size = target.stat().st_size
if max_bytes is not None and size > max_bytes:
raise ServiceError(
f"File too large ({size} bytes > {max_bytes})",
code="file_too_large",
status=413,
details={"vault": vault_name, "path": path, "size": size},
)
content = target.read_text(encoding="utf-8", errors="replace")
if redact:
from backend.secret_redactor import redact_file_content
content = redact_file_content(content, path)
return {"vault": vault_name, "path": path, "size": size, "content": content}
+45
View File
@@ -0,0 +1,45 @@
"""Vault path resolution shared by the REST routes and the AI tool layer.
This is the single implementation of the anti path-traversal check. Routes map
:class:`ServiceError` to ``HTTPException`` and tools map it to
:class:`backend.tools.context.ToolError`.
"""
from __future__ import annotations
import logging
from pathlib import Path
from backend.services.errors import ServiceError
logger = logging.getLogger("obsigate.services.paths")
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a vault-relative path, rejecting traversal outside the vault.
Raises:
ServiceError: ``path_error`` (500) when the path cannot be resolved,
``path_outside_vault`` (403) when it escapes the vault root.
"""
full_path = vault_root / (relative_path or "")
try:
resolved = full_path.resolve(strict=False)
root = vault_root.resolve(strict=False)
except Exception as e:
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e
try:
resolved.relative_to(root)
except ValueError:
# Case-insensitive fallback for Windows / Docker path casing.
if not str(resolved).lower().startswith(str(root).lower()):
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
raise ServiceError(
"Access denied: path outside vault",
code="path_outside_vault",
status=403,
details={"path": relative_path},
) from None
return resolved
+41
View File
@@ -0,0 +1,41 @@
"""Search services shared by REST routes and the AI tool layer."""
from __future__ import annotations
from typing import Any
def search_vaults(
q: str,
vault: str = "all",
tag: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
"""Full-text search with pagination, returned as the API response payload.
No permission filtering is applied here: callers that need it (the tool
layer) filter the ``results`` list themselves.
"""
from backend.search import search
all_results = search(q, vault_filter=vault, tag_filter=tag)
total = len(all_results)
page = all_results[offset: offset + limit]
return {
"query": q,
"vault_filter": vault,
"tag_filter": tag,
"count": len(page),
"total": total,
"offset": offset,
"limit": limit,
"results": page,
}
def list_tags(vault: str | None = None) -> dict[str, int]:
"""Return tag → count, optionally restricted to a single vault."""
from backend.search import get_all_tags
return get_all_tags(vault_filter=vault)
+102
View File
@@ -0,0 +1,102 @@
"""Vault listing and directory browsing services.
Single source of truth consumed by both the REST routes (``/api/vaults``,
``/api/browse/{vault}``) and the AI tool layer (``list_vaults``,
``list_directory``).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
"""Return the vaults *user* may access, with summary metadata."""
from backend.auth.middleware import check_vault_access
from backend.indexer import index
result: list[dict[str, Any]] = []
for name, data in index.items():
if not check_vault_access(name, user):
continue
result.append({
"name": name,
"file_count": len(data.get("files", [])),
"tag_count": len(data.get("tags", {})),
"type": data.get("config", {}).get("type", "VAULT"),
})
return result
def get_vault_root(vault_name: str) -> Path:
"""Return the filesystem root of *vault_name* or raise ``not_found``."""
from backend.indexer import get_vault_data
data = get_vault_data(vault_name)
if not data:
raise ServiceError(
f"Vault '{vault_name}' not found",
code="not_found",
status=404,
details={"vault": vault_name},
)
return Path(data["path"])
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"""Return the direct children of a vault directory (directories first)."""
from backend.indexer import SUPPORTED_EXTENSIONS
from backend.vault_settings import get_vault_setting
root = get_vault_root(vault_name)
target = resolve_safe_path(root, path) if path else root.resolve()
if not target.exists():
raise ServiceError(
f"Path not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
hide_hidden = (get_vault_setting(vault_name) or {}).get("hideHiddenFiles", False)
items: list[dict[str, Any]] = []
try:
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if hide_hidden and entry.name.startswith("."):
continue
rel = str(entry.relative_to(root)).replace("\\", "/")
if entry.is_dir():
# Count only direct children (files and subdirs) for performance.
try:
file_count = sum(
1 for child in entry.iterdir()
if (not hide_hidden or not child.name.startswith("."))
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
or child.is_dir())
)
except PermissionError:
file_count = 0
items.append({
"name": entry.name,
"path": rel,
"type": "directory",
"children_count": file_count,
})
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
items.append({
"name": entry.name,
"path": rel,
"type": "file",
"size": entry.stat().st_size,
"extension": entry.suffix.lower(),
})
except PermissionError:
raise ServiceError("Permission denied", code="permission_denied", status=403) from None
return {"vault": vault_name, "path": path, "items": items}
+11 -21
View File
@@ -15,6 +15,8 @@ from pathlib import Path
from typing import Any from typing import Any
from backend.auth.middleware import check_vault_access from backend.auth.middleware import check_vault_access
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path as _resolve_service_path
logger = logging.getLogger("obsigate.tools") logger = logging.getLogger("obsigate.tools")
@@ -138,32 +140,20 @@ class ToolContext:
) )
def resolve_safe_path(vault_root: Path, relative_path: str) -> Path: def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a vault-relative path, rejecting traversal outside the vault. """Resolve a vault-relative path, rejecting traversal outside the vault.
Mirrors ``backend.main._resolve_safe_path`` but raises a domain error Delegates to the shared :func:`backend.services.paths.resolve_safe_path`
instead of ``HTTPException`` so the tool layer stays transport-agnostic. and maps its :class:`ServiceError` to tool domain errors so the tool layer
stays transport-agnostic.
Raises: Raises:
ToolPermissionError: When the resolved path escapes the vault root. ToolPermissionError: When the resolved path escapes the vault root.
ToolError: When the path cannot be resolved. ToolError: When the path cannot be resolved.
""" """
full_path = vault_root / (relative_path or "")
try: try:
resolved = full_path.resolve(strict=False) return _resolve_service_path(vault_root, relative_path)
root = vault_root.resolve(strict=False) except ServiceError as e:
except Exception as e: if e.code in ("path_outside_vault", "permission_denied"):
raise ToolError(f"Path resolution error: {e}", code="path_error") from e raise ToolPermissionError(e.message, code=e.code, details=e.details) from e
raise ToolError(e.message, code=e.code, details=e.details) from e
try:
resolved.relative_to(root)
except ValueError:
# Case-insensitive fallback for Windows / Docker path casing.
if not str(resolved).lower().startswith(str(root).lower()):
logger.warning(f"Path outside vault: vault={root}, requested={relative_path}")
raise ToolPermissionError(
"Access denied: path outside vault",
code="path_outside_vault",
details={"path": relative_path},
) from None
return resolved
+16
View File
@@ -18,6 +18,7 @@ from typing import Any
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from backend.services.errors import ServiceError
from backend.tools.audit import log_tool_call from backend.tools.audit import log_tool_call
from backend.tools.context import ( from backend.tools.context import (
ToolConfirmationRequired, ToolConfirmationRequired,
@@ -136,6 +137,17 @@ def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: b
logger.debug(f"Tool audit failed for '{spec.name}': {e}") logger.debug(f"Tool audit failed for '{spec.name}': {e}")
def _map_service_error(e: ServiceError) -> ToolError:
"""Map a shared-layer :class:`ServiceError` to a tool domain error."""
if e.code == "not_found":
return ToolNotFoundError(e.message, details=e.details)
if e.code in ("permission_denied", "path_outside_vault", "vault_access_denied"):
return ToolPermissionError(e.message, code=e.code, details=e.details)
if e.code == "invalid_arguments":
return ToolValidationError(e.message, details=e.details)
return ToolError(e.message, code=e.code, details=e.details)
def call_tool( def call_tool(
name: str, name: str,
ctx: ToolContext, ctx: ToolContext,
@@ -191,6 +203,10 @@ def call_tool(
except ToolError as e: except ToolError as e:
_audit(ctx, spec, arguments, ok=False, error=e.code) _audit(ctx, spec, arguments, ok=False, error=e.code)
raise raise
except ServiceError as e:
mapped = _map_service_error(e)
_audit(ctx, spec, arguments, ok=False, error=mapped.code)
raise mapped from e
except Exception as e: except Exception as e:
logger.error(f"Tool '{name}' failed: {e}") logger.error(f"Tool '{name}' failed: {e}")
exec_error = ToolError(f"Tool '{name}' failed: {e}", code="tool_execution_error") exec_error = ToolError(f"Tool '{name}' failed: {e}", code="tool_execution_error")
+26 -79
View File
@@ -1,24 +1,21 @@
"""Built-in tool services (Phase 0 — read/search). """Built-in tool services (Phase 0 — read/search).
These functions are the single source of truth consumed by both the in-app These functions are the single source of truth consumed by both the in-app
assistant and the MCP server. They delegate to the existing core modules assistant and the MCP server. They delegate to the shared business-logic
(``backend.indexer``, ``backend.search``) rather than duplicating route logic. services (``backend.services``) so routes and tools never diverge.
Mutating tools (create/edit/rename/move/delete) are added in later phases. Mutating tools (create/edit/rename/move/delete) are added in later phases.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path
from typing import Any from typing import Any
from backend.tools.context import ( from backend.services.files import read_file_text
ToolContext, from backend.services.search import list_tags as _list_tags
ToolError, from backend.services.search import search_vaults
ToolNotFoundError, from backend.services.vaults import browse_directory, list_accessible_vaults
ToolRisk, from backend.tools.context import ToolContext, ToolRisk
resolve_safe_path,
)
from backend.tools.registry import tool from backend.tools.registry import tool
from backend.tools.schemas import ( from backend.tools.schemas import (
ListDirectoryInput, ListDirectoryInput,
@@ -34,15 +31,6 @@ logger = logging.getLogger("obsigate.tools.service")
TOOL_MAX_READ_BYTES = 200_000 TOOL_MAX_READ_BYTES = 200_000
def _vault_data_or_raise(vault: str) -> dict[str, Any]:
from backend.indexer import get_vault_data
data = get_vault_data(vault)
if not data:
raise ToolNotFoundError(f"Vault '{vault}' not found", details={"vault": vault})
return data
@tool( @tool(
name="list_vaults", name="list_vaults",
description="List the vaults the current user is allowed to access.", description="List the vaults the current user is allowed to access.",
@@ -51,15 +39,10 @@ def _vault_data_or_raise(vault: str) -> dict[str, Any]:
) )
def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]: def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]:
"""Return accessible vaults with a file count.""" """Return accessible vaults with a file count."""
from backend.indexer import get_vault_data, get_vault_names return [
{"name": v["name"], "file_count": v["file_count"]}
vaults: list[dict[str, Any]] = [] for v in list_accessible_vaults(ctx.user)
for name in get_vault_names(): ]
if not ctx.has_vault_access(name):
continue
data = get_vault_data(name) or {}
vaults.append({"name": name, "file_count": len(data.get("files", []))})
return vaults
@tool( @tool(
@@ -71,28 +54,11 @@ def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, An
) )
def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]: def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]:
"""Return the entries of a vault directory (direct children only).""" """Return the entries of a vault directory (direct children only)."""
data = _vault_data_or_raise(params.vault) data = browse_directory(params.vault, params.path)
root = Path(data["path"]) return [
target = resolve_safe_path(root, params.path) if params.path else root.resolve() {"name": item["name"], "path": item["path"], "type": item["type"]}
for item in data["items"]
if not target.exists() or not target.is_dir(): ]
raise ToolNotFoundError(f"Directory not found: {params.path}", details={"vault": params.vault, "path": params.path})
from backend.vault_settings import get_vault_setting
hide_hidden = (get_vault_setting(params.vault) or {}).get("hideHiddenFiles", False)
items: list[dict[str, Any]] = []
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if hide_hidden and entry.name.startswith("."):
continue
rel = str(entry.relative_to(root)).replace("\\", "/")
items.append({
"name": entry.name,
"path": rel,
"type": "directory" if entry.is_dir() else "file",
})
return items
@tool( @tool(
@@ -104,27 +70,12 @@ def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[st
) )
def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]: def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]:
"""Return the (redacted) content of a vault file.""" """Return the (redacted) content of a vault file."""
data = _vault_data_or_raise(params.vault) return read_file_text(
root = Path(data["path"]) params.vault,
target = resolve_safe_path(root, params.path) params.path,
redact=True,
if not target.exists() or not target.is_file(): max_bytes=TOOL_MAX_READ_BYTES,
raise ToolNotFoundError(f"File not found: {params.path}", details={"vault": params.vault, "path": params.path}) )
size = target.stat().st_size
if size > TOOL_MAX_READ_BYTES:
raise ToolError(
f"File too large ({size} bytes > {TOOL_MAX_READ_BYTES})",
code="file_too_large",
details={"vault": params.vault, "path": params.path, "size": size},
)
content = target.read_text(encoding="utf-8", errors="replace")
from backend.secret_redactor import redact_file_content
content = redact_file_content(content, params.path)
return {"vault": params.vault, "path": params.path, "size": size, "content": content}
@tool( @tool(
@@ -135,10 +86,8 @@ def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]:
) )
def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[str, Any]]: def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[str, Any]]:
"""Return ranked search results, filtered to accessible vaults.""" """Return ranked search results, filtered to accessible vaults."""
from backend.search import search payload = search_vaults(params.q, vault=params.vault, tag=params.tag, limit=params.limit)
return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])]
results = search(params.q, vault_filter=params.vault, tag_filter=params.tag, limit=params.limit)
return [r for r in results if ctx.has_vault_access(r["vault"])]
@tool( @tool(
@@ -149,11 +98,9 @@ def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[
) )
def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]: def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]:
"""Return tags sorted by descending count.""" """Return tags sorted by descending count."""
from backend.search import get_all_tags
if params.vault and params.vault != "all": if params.vault and params.vault != "all":
ctx.require_vault_access(params.vault) ctx.require_vault_access(params.vault)
return [{"tag": tag, "count": count} for tag, count in get_all_tags(params.vault).items()] return [{"tag": tag, "count": count} for tag, count in _list_tags(params.vault).items()]
from backend.indexer import get_vault_names from backend.indexer import get_vault_names
@@ -161,6 +108,6 @@ def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]:
for name in get_vault_names(): for name in get_vault_names():
if not ctx.has_vault_access(name): if not ctx.has_vault_access(name):
continue continue
for tag, count in get_all_tags(name).items(): for tag, count in _list_tags(name).items():
merged[tag] = merged.get(tag, 0) + count merged[tag] = merged.get(tag, 0) + count
return [{"tag": tag, "count": count} for tag, count in sorted(merged.items(), key=lambda x: -x[1])] return [{"tag": tag, "count": count} for tag, count in sorted(merged.items(), key=lambda x: -x[1])]
+22 -3
View File
@@ -82,7 +82,7 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même
1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes. 1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes.
2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle. 2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle.
3. **SSE non réellement streaming** : la réponse complète est envoyée en un seul événement — `backend/bookslm_routes.py:211`. 3. **SSE** : ~~non réellement streaming~~ → **corrigé** (B4) : `ai_chat.stream_completion` alimente `/api/ai/bookslm/chat` token par token ; le tool-calling (`/agent`) reste non-streaming (les appels d'outils exigent la réponse complète).
4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI). 4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI).
--- ---
@@ -93,6 +93,22 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même
Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services. Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services.
**Nouveau module `backend/services/` (A2 — lecture/recherche) :**
```
backend/services/
├── errors.py # ServiceError (code + status HTTP)
├── paths.py # resolve_safe_path (anti path-traversal, source unique)
├── vaults.py # list_accessible_vaults, browse_directory, get_vault_root
├── files.py # read_raw_file, read_file_text (redaction + quota)
└── search.py # search_vaults (pagination), list_tags
```
Les routes `/api/vaults`, `/api/browse/{vault}`, `/api/file/{vault}/raw`, `/api/search` et
`/api/tags` en sont de simples wrappers, tout comme les outils `list_vaults`, `list_directory`,
`read_file`, `search_fulltext` et `list_tags`. Un `ServiceError` est traduit en `HTTPException`
(handler global de `backend/main.py`) ou en `ToolError` (`backend/tools/registry.py`).
**Nouveau module `backend/tools/` :** **Nouveau module `backend/tools/` :**
``` ```
@@ -129,7 +145,9 @@ boucle (max N itérations):
``` ```
- Fallback protocole texte (`obsigate-action`) si le modèle ne supporte pas les tools. - Fallback protocole texte (`obsigate-action`) si le modèle ne supporte pas les tools.
- SSE réellement streaming. - SSE réellement streaming sur `/chat` (`ai_chat.stream_completion`) ; `/agent` reste buffered
(les appels d'outils exigent la réponse complète avant exécution). Confirmations UI en deux temps
(`confirmation` → `confirm`/`confirm_messages`).
### 3.4 Couche 4 — Serveur MCP ### 3.4 Couche 4 — Serveur MCP
@@ -288,7 +306,8 @@ Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
## 9. Références ## 9. Références
- `backend/ai.py`, `backend/ai_routes.py` — couche fournisseurs + actions éditeur - `backend/ai.py`, `backend/ai_routes.py` — couche fournisseurs + actions éditeur
- `backend/ai_chat.py` — chat completion provider-agnostique avec tool calling (OpenAI-compat + Gemini) - `backend/services/` — logique métier partagée (vaults, files, search) consommée par les routes et les outils
- `backend/ai_chat.py` — chat completion provider-agnostique avec tool calling et streaming (OpenAI-compat + Gemini)
- `backend/agent/loop.py` — agent loop in-app (multi-étapes, LLM injectable) - `backend/agent/loop.py` — agent loop in-app (multi-étapes, LLM injectable)
- `backend/bookslm.py`, `backend/bookslm_routes.py` — assistant contextuel (+ endpoint `/agent`) - `backend/bookslm.py`, `backend/bookslm_routes.py` — assistant contextuel (+ endpoint `/agent`)
- `frontend/js/ai.js`, `frontend/js/bookslm.js` — UI IA - `frontend/js/ai.js`, `frontend/js/bookslm.js` — UI IA
+2 -4
View File
@@ -43,11 +43,9 @@
### 79. Assistant IA — Outils (function calling) & serveur MCP ### 79. Assistant IA — Outils (function calling) & serveur MCP
- **Effort :** 10-15 jours | **Impact :** 🟡 - **Effort :** 10-15 jours | **Impact :** 🟡
- **Statut :** 🔵 Phase 0 + B1/B2/B3/B7 + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) - **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md)
- **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**. - **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**.
- **Reste à faire :** - **Reste à faire :**
- [ ] **A2.** Extraire la logique métier des routes `backend/main.py` en services réutilisables (partiel)
- [ ] **B4.** SSE réellement streaming ; **B5.** confirmations UI (carte Apply, diff) ; **B6.** outils navigation `open_file`/`reveal_in_tree`
- [ ] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags) - [ ] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags)
- [ ] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step - [ ] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step
- [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault) - [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault)
@@ -182,7 +180,7 @@
|---|---|---| |---|---|---|
| ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés | | ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours | | 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours | | ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours |
| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours | | ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours |
| **Total restant** | **6 items + finitions** | **~27-39 jours** | | **Total restant** | **6 items + finitions** | **~27-39 jours** |
+5 -5
View File
@@ -1,6 +1,6 @@
# #79 — Assistant IA — Outils (function calling) & serveur MCP # #79 — Assistant IA — Outils (function calling) & serveur MCP
> **Statut :** 🔵 En cours — Phase 0 + B1/B2/B3/B6/B7 + G livrés (2026-09-11) > **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + G livrés (2026-09-11)
> **Effort :** 10-15 jours | **Impact :** 🟡 > **Effort :** 10-15 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md) > **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md)
@@ -9,17 +9,17 @@
## A. Fondations — couche d'outils partagée (2-3 jours) — ✅ livré (2026-09-11) ## A. Fondations — couche d'outils partagée (2-3 jours) — ✅ livré (2026-09-11)
- [x] **A1.** Créer `backend/tools/` : `context.py` (`ToolContext` : user, allowed_vaults, mode, confirmed), `registry.py` (décorateur `@tool` + schéma JSON), `schemas.py`, `service.py`, `audit.py` - [x] **A1.** Créer `backend/tools/` : `context.py` (`ToolContext` : user, allowed_vaults, mode, confirmed), `registry.py` (décorateur `@tool` + schéma JSON), `schemas.py`, `service.py`, `audit.py`
- [ ] **A2.** Extraire la logique métier des routes de `backend/main.py` en fonctions de service réutilisables (les routes deviennent des wrappers) — *partiel : services lecture/recherche livrés (`list_vaults`, `list_directory`, `read_file`, `search_fulltext`, `list_tags`) ; refactor des routes `main.py` à suivre* - [x] **A2.** Extraire la logique métier des routes de `backend/main.py` en fonctions de service réutilisables (les routes deviennent des wrappers) — `backend/services/` (`errors.py`, `paths.py`, `vaults.py`, `files.py`, `search.py`) ; routes `/api/vaults`, `/api/browse`, `/api/file/{vault}/raw`, `/api/search`, `/api/tags` et outils `list_vaults`/`list_directory`/`read_file`/`search_fulltext`/`list_tags` délèguent à la même couche. Les `ServiceError` sont mappées vers `HTTPException` (routes) et `ToolError` (outils).
- [x] **A3.** Contexte de permissions : chaque outil applique `check_vault_access` + `resolve_safe_path` - [x] **A3.** Contexte de permissions : chaque outil applique `check_vault_access` + `resolve_safe_path`
- [x] **A4.** Audit : journalisation JSONL de chaque appel d'outil (qui, quoi, vault, résultat) — action `ai_tool_call`, arguments sensibles résumés - [x] **A4.** Audit : journalisation JSONL de chaque appel d'outil (qui, quoi, vault, résultat) — action `ai_tool_call`, arguments sensibles résumés
- [x] **A5.** Tests unitaires du registry + services (sans IA) — `tests/test_tools.py` (30 tests) - [x] **A5.** Tests unitaires du registry + services (sans IA) — `tests/test_tools.py` (30 tests)
## B. Function calling in-app (3-4 jours) — 🔵 partiel (B1/B2/B3/B7 livrés 2026-09-11) ## B. Function calling in-app (3-4 jours) — ✅ livré (2026-09-11)
- [x] **B1.** Abstraction tool-calling provider-agnostique : `backend/ai_chat.py` (`chat_completion`, `ToolCall`, `LLMResponse`) — OpenAI-compat (`tools`/`tool_choice`, parsing `tool_calls`) + Gemini (`functionDeclarations`/`functionCall`) - [x] **B1.** Abstraction tool-calling provider-agnostique : `backend/ai_chat.py` (`chat_completion`, `ToolCall`, `LLMResponse`) — OpenAI-compat (`tools`/`tool_choice`, parsing `tool_calls`) + Gemini (`functionDeclarations`/`functionCall`)
- [x] **B2.** Agent loop `backend/agent/loop.py` : boucle tool→résultat→tool, limite d'itérations (10), truncation des résultats ; endpoint opt-in `POST /api/ai/bookslm/agent` (events SSE `tool`/`message`/`confirmation`) - [x] **B2.** Agent loop `backend/agent/loop.py` : boucle tool→résultat→tool, limite d'itérations (10), truncation des résultats ; endpoint opt-in `POST /api/ai/bookslm/agent` (events SSE `tool`/`message`/`confirmation`)
- [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend - [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend
- [ ] **B4.** SSE réellement streaming (corriger `bookslm_routes.py` — le message final reste envoyé en un seul événement) - [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM.
- [ ] **B5.** Confirmations UI : outils `read` auto, outils `write` via carte Apply (`bookslm.js:515`), aperçu diff pour `edit_file` - [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.*
- [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md)) - [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md))
- [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`) - [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`)
+5
View File
@@ -4203,6 +4203,11 @@
(recharger ou supprimer) ; « + » démarre une nouvelle (recharger ou supprimer) ; « + » démarre une nouvelle
conversation. conversation.
</li> </li>
<li data-i18n="help.assistant_agent">
Le bouton « mode agent » active les outils (lire, lister,
chercher) ; les actions de modification demandent une
confirmation avec aperçu des changements.
</li>
</ul> </ul>
</section> </section>
+307 -74
View File
@@ -74,6 +74,36 @@ class BooksLM {
this._sessions = []; this._sessions = [];
this._currentSessionId = null; this._currentSessionId = null;
this._pendingNewSession = false; this._pendingNewSession = false;
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
// call read/search tools and propose mutations (confirmation cards).
this._agentMode = this._readAgentMode();
}
_readAgentMode() {
try {
return localStorage.getItem('obsigate-bookslm-agent') === 'true';
} catch {
return false;
}
}
_toggleAgentMode() {
this._agentMode = !this._agentMode;
try {
localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false');
} catch { /* private mode */ }
this._updateAgentToggle();
}
_updateAgentToggle() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-agent');
if (!btn) return;
btn.classList.toggle('active', this._agentMode);
const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off');
btn.title = label;
btn.setAttribute('aria-label', label);
btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false');
} }
// ── Public API ────────────────────────────────────────────────────── // ── Public API ──────────────────────────────────────────────────────
@@ -376,6 +406,7 @@ class BooksLM {
<span class="bookslm-subtitle"></span> <span class="bookslm-subtitle"></span>
</div> </div>
<div class="bookslm-header-actions"> <div class="bookslm-header-actions">
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button> <button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button> <button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button> <button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
@@ -409,6 +440,7 @@ class BooksLM {
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close()); panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation()); panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode());
panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => { panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this._toggleHistoryMenu(); this._toggleHistoryMenu();
@@ -567,12 +599,18 @@ class BooksLM {
if (msg.role === 'assistant') { if (msg.role === 'assistant') {
const { text, actions } = this._extractActions(msg.content || ''); const { text, actions } = this._extractActions(msg.content || '');
bubble.innerHTML = this._renderMarkdown(text); bubble.innerHTML = this._renderMarkdown(text);
if (msg.toolCalls && msg.toolCalls.length) {
bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild);
}
if (actions.length) { if (actions.length) {
const actionsDiv = document.createElement('div'); const actionsDiv = document.createElement('div');
actionsDiv.className = 'bookslm-actions'; actionsDiv.className = 'bookslm-actions';
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action))); actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
bubble.appendChild(actionsDiv); bubble.appendChild(actionsDiv);
} }
if (msg.confirmation) {
bubble.appendChild(this._renderConfirmationCard(msg));
}
if (msg.sources && msg.sources.length) { if (msg.sources && msg.sources.length) {
const sourcesDiv = document.createElement('div'); const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'bookslm-sources'; sourcesDiv.className = 'bookslm-sources';
@@ -673,6 +711,172 @@ class BooksLM {
} catch { /* tree refresh is best-effort */ } } catch { /* tree refresh is best-effort */ }
} }
// ── Tool activity & confirmations (agent mode) ──────────────────────
_renderToolActivity(toolCalls) {
const wrap = document.createElement('div');
wrap.className = 'bookslm-tool-trace';
for (const call of toolCalls) {
const line = document.createElement('div');
line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : '');
line.textContent = `${call.ok === false ? '⚠' : '🔧'} ${t('ai.tool_call', { name: call.name })}`;
if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name });
wrap.appendChild(line);
}
return wrap;
}
_renderConfirmationCard(msg) {
const conf = msg.confirmation;
const pending = conf.pending || {};
const error = pending.error || pending;
const tool = error.tool || 'action';
const args = error.arguments || {};
const card = document.createElement('div');
card.className = 'bookslm-action bookslm-confirm';
const meta = document.createElement('div');
meta.className = 'bookslm-action-meta';
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
const textEl = document.createElement('span');
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
meta.appendChild(textEl);
card.appendChild(meta);
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (conf._diffHtml) {
diffHost.innerHTML = conf._diffHtml;
} else if (!conf._diffLoading) {
conf._diffLoading = true;
this._fillConfirmationDiff(args, conf).then(() => this._renderMessages());
}
card.appendChild(diffHost);
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._applyConfirmation(msg);
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 _fillConfirmationDiff(args, conf) {
const proposed = args.content;
if (typeof proposed !== 'string' || !args.vault || !args.path) return;
let current = null;
try {
current = await this._fetchCurrentContent(args.vault, args.path);
} catch {
current = null;
}
const title = `<div class="bookslm-diff-title">${this._escapeHtml(t('ai.diff_preview'))}</div>`;
if (current == null) {
// New file (or unreadable): show everything as an addition.
conf._diffHtml = title + `<pre class="bookslm-diff">${this._renderDiffLines([{ t: '+', s: proposed }])}</pre>`;
return;
}
const lines = this._lineDiff(current, proposed);
conf._diffHtml = title + `<pre class="bookslm-diff">${this._renderDiffLines(lines)}</pre>`;
}
async _fetchCurrentContent(vault, path) {
const data = await api(`/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`);
return data && typeof data.raw === 'string' ? data.raw : '';
}
/** Minimal line diff (LCS) between the current and the proposed content. */
_lineDiff(oldText, newText) {
const a = String(oldText || '').split('\n');
const b = String(newText || '').split('\n');
const n = a.length;
const m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { out.push({ t: ' ', s: a[i] }); i++; j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: '-', s: a[i] }); i++; }
else { out.push({ t: '+', s: b[j] }); j++; }
}
while (i < n) out.push({ t: '-', s: a[i++] });
while (j < m) out.push({ t: '+', s: b[j++] });
return out;
}
_renderDiffLines(lines) {
const cap = 400;
const shown = lines.length > cap ? lines.slice(0, cap) : lines;
let html = shown.map((l) => {
const cls = l.t === '+' ? 'bookslm-diff-add' : (l.t === '-' ? 'bookslm-diff-del' : 'bookslm-diff-ctx');
return `<span class="${cls}">${this._escapeHtml(l.t + ' ' + l.s)}</span>`;
}).join('\n');
if (lines.length > cap) {
html += `\n<span class="bookslm-diff-ctx">… ${lines.length - cap}</span>`;
}
return html;
}
async _applyConfirmation(msg) {
const conf = msg.confirmation;
if (!conf || conf._applying) return;
conf._applying = true;
const payload = {
...(msg.payload || {}),
confirm: conf.pending,
confirm_messages: conf.messages,
};
this._isLoading = true;
this._abortCtrl = new AbortController();
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: null };
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
await AuthManager.refreshAccessToken();
resp = await this._postChat(payload);
}
if (!resp.ok) {
let detail = `HTTP ${resp.status}`;
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
throw new Error(detail);
}
// The confirmation is resolved: drop the card and show the continuation.
msg.confirmation = null;
this._messages.push(continuation);
await this._streamResponse(resp, continuation, payload);
} finally {
conf._applying = false;
this._isLoading = false;
this._abortCtrl = null;
if (sendBtn) sendBtn.disabled = false;
this._renderMessages();
this._saveHistory();
}
}
// ── Messaging ─────────────────────────────────────────────────────── // ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() { async _sendMessage() {
@@ -694,7 +898,26 @@ class BooksLM {
this._messages.push({ role: 'user', content: text }); this._messages.push({ role: 'user', content: text });
this._renderMessages(); this._renderMessages();
const assistantMsg = { role: 'assistant', content: '', sources: [] }; let provider = null;
let model = null;
try {
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
provider = picker.provider || null;
model = picker.model || null;
} 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,
provider,
model,
};
const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload };
this._messages.push(assistantMsg); this._messages.push(assistantMsg);
this._renderMessages(); this._renderMessages();
this._isLoading = true; this._isLoading = true;
@@ -705,39 +928,10 @@ class BooksLM {
this._abortCtrl = new AbortController(); this._abortCtrl = new AbortController();
try { try {
let provider = null; let resp = await this._postChat(payload);
let model = null;
try {
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
provider = picker.provider || null;
model = picker.model || null;
} 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,
provider,
model,
};
const doFetch = () => {
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch('/api/ai/bookslm/chat', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(payload),
signal: this._abortCtrl.signal,
});
};
let resp = await doFetch();
if (resp.status === 401 && AuthManager._authEnabled) { if (resp.status === 401 && AuthManager._authEnabled) {
await AuthManager.refreshAccessToken(); await AuthManager.refreshAccessToken();
resp = await doFetch(); resp = await this._postChat(payload);
} }
if (!resp.ok) { if (!resp.ok) {
let detail = `HTTP ${resp.status}`; let detail = `HTTP ${resp.status}`;
@@ -745,46 +939,7 @@ class BooksLM {
throw new Error(detail); throw new Error(detail);
} }
const reader = resp.body.getReader(); await this._streamResponse(resp, assistantMsg, payload);
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = 'message';
const handleLine = (line) => {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
return;
}
if (!line.startsWith('data:')) return;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') return;
let data;
try {
data = JSON.parse(raw);
} catch {
assistantMsg.content += raw;
return;
}
if (currentEvent === 'error' || data.error) {
const msg = data.error || t('bookslm.error');
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
return;
}
const chunk = data.token || data.content || '';
if (chunk) assistantMsg.content += chunk;
if (data.sources) assistantMsg.sources = data.sources;
};
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);
if (!assistantMsg.sources.length && this._contextFiles.length) { if (!assistantMsg.sources.length && this._contextFiles.length) {
const contentLower = assistantMsg.content.toLowerCase(); const contentLower = assistantMsg.content.toLowerCase();
@@ -809,6 +964,78 @@ class BooksLM {
this._saveHistory(); this._saveHistory();
} }
/** POST to /chat or /agent depending on the agent-mode toggle. */
_postChat(payload) {
const endpoint = this._agentMode ? '/api/ai/bookslm/agent' : '/api/ai/bookslm/chat';
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch(endpoint, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(payload),
signal: this._abortCtrl ? this._abortCtrl.signal : undefined,
});
}
/**
* Consume an SSE response, feeding tokens/tool/confirmation events into
* `assistantMsg`. Shared by the initial send and the confirmation resume.
*/
async _streamResponse(resp, assistantMsg, payload) {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = 'message';
const handleLine = (line) => {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
return;
}
if (!line.startsWith('data:')) return;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') return;
let data;
try {
data = JSON.parse(raw);
} catch {
assistantMsg.content += raw;
return;
}
if (currentEvent === 'tool') {
assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} });
return;
}
if (currentEvent === 'confirmation') {
assistantMsg.confirmation = {
pending: data.pending || data,
messages: data.messages || [],
};
return;
}
if (currentEvent === 'error' || data.error) {
const msg = data.error || t('bookslm.error');
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
return;
}
const chunk = data.token || data.content || '';
if (chunk) assistantMsg.content += chunk;
if (data.sources) assistantMsg.sources = data.sources;
};
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);
}
// ── Markdown (self-contained renderer) ────────────────────────────── // ── Markdown (self-contained renderer) ──────────────────────────────
// //
// Renders the assistant answer as formatted HTML (headings, lists, tables, // Renders the assistant answer as formatted HTML (headings, lists, tables,
@@ -1167,14 +1394,20 @@ class BooksLM {
} catch { /* quota / private mode */ } } catch { /* quota / private mode */ }
} }
/** Strip transient (large) fields before persisting a conversation. */
_persistableMessages() {
return this._messages.map(({ confirmation, payload, ...rest }) => rest);
}
_saveHistory() { _saveHistory() {
const persisted = this._persistableMessages();
const session = this._currentSession(); const session = this._currentSession();
if (session) { if (session) {
session.messages = this._messages; session.messages = persisted;
session.updatedAt = Date.now(); session.updatedAt = Date.now();
if (!session.title) session.title = this._deriveTitle(this._messages); if (!session.title) session.title = this._deriveTitle(this._messages);
} else { } else {
const created = this._createSession(this._messages); const created = this._createSession(persisted);
created.title = this._deriveTitle(this._messages); created.title = this._deriveTitle(this._messages);
this._sessions.unshift(created); this._sessions.unshift(created);
this._currentSessionId = created.id; this._currentSessionId = created.id;
+6
View File
@@ -86,6 +86,8 @@
"ai.action_created_dir": "Folder created: {path}", "ai.action_created_dir": "Folder created: {path}",
"ai.action_created_file": "File created: {path}", "ai.action_created_file": "File created: {path}",
"ai.action_failed": "Action failed: {error}", "ai.action_failed": "Action failed: {error}",
"ai.agent_mode_off": "Agent mode off (read/search + actions)",
"ai.agent_mode_on": "Agent mode on (read/search tools + actions)",
"ai.casual": "Casual tone", "ai.casual": "Casual tone",
"ai.close": "Close assistant", "ai.close": "Close assistant",
"ai.completion_added": "AI: completion added", "ai.completion_added": "AI: completion added",
@@ -94,6 +96,7 @@
"ai.context_general_desc": "Questions about the app and file creation", "ai.context_general_desc": "Questions about the app and file creation",
"ai.continue": "Continue", "ai.continue": "Continue",
"ai.custom_rewrite": "Custom rewrite", "ai.custom_rewrite": "Custom rewrite",
"ai.diff_preview": "Change preview",
"ai.done": "AI: text processed", "ai.done": "AI: text processed",
"ai.edit": "Edit", "ai.edit": "Edit",
"ai.error_invalid_key": "Invalid API key", "ai.error_invalid_key": "Invalid API key",
@@ -149,6 +152,8 @@
"ai.to_list": "Convert to list", "ai.to_list": "Convert to list",
"ai.to_table": "Convert to table", "ai.to_table": "Convert to table",
"ai.tone_hint": "Changes the text tone", "ai.tone_hint": "Changes the text tone",
"ai.tool_call": "Tool: {name}",
"ai.tool_call_failed": "Tool {name} failed",
"ai.toolbar_edit": "Edit", "ai.toolbar_edit": "Edit",
"ai.toolbar_generate": "Generate", "ai.toolbar_generate": "Generate",
"ai.toolbar_rewrite": "Rewrite", "ai.toolbar_rewrite": "Rewrite",
@@ -1212,6 +1217,7 @@
"help.assistant_markdown": "Formatted answers: headings, lists, tables, quotes and code blocks.", "help.assistant_markdown": "Formatted answers: headings, lists, tables, quotes and code blocks.",
"help.assistant_links": "Cited files and paths are links: click a file to open it, a folder to reveal it in the tree.", "help.assistant_links": "Cited files and paths are links: click a file to open it, a folder to reveal it in the tree.",
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.", "help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
"help.toolbar_section": "AI toolbar", "help.toolbar_section": "AI toolbar",
"help.tree_section": "Vault tree", "help.tree_section": "Vault tree",
"help.use_cases": "Use cases", "help.use_cases": "Use cases",
+6
View File
@@ -86,6 +86,8 @@
"ai.action_created_dir": "Dossier créé : {path}", "ai.action_created_dir": "Dossier créé : {path}",
"ai.action_created_file": "Fichier créé : {path}", "ai.action_created_file": "Fichier créé : {path}",
"ai.action_failed": "Échec de l'action : {error}", "ai.action_failed": "Échec de l'action : {error}",
"ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)",
"ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)",
"ai.casual": "Ton décontracté", "ai.casual": "Ton décontracté",
"ai.close": "Fermer l'assistant", "ai.close": "Fermer l'assistant",
"ai.completion_added": "AI: complétion ajoutée", "ai.completion_added": "AI: complétion ajoutée",
@@ -94,6 +96,7 @@
"ai.context_general_desc": "Questions sur l'application et création de fichiers", "ai.context_general_desc": "Questions sur l'application et création de fichiers",
"ai.continue": "Continuer", "ai.continue": "Continuer",
"ai.custom_rewrite": "Réécriture personnalisée", "ai.custom_rewrite": "Réécriture personnalisée",
"ai.diff_preview": "Aperçu des modifications",
"ai.done": "AI: texte traité", "ai.done": "AI: texte traité",
"ai.edit": "Éditer", "ai.edit": "Éditer",
"ai.error_invalid_key": "clé API invalide", "ai.error_invalid_key": "clé API invalide",
@@ -149,6 +152,8 @@
"ai.to_list": "Convertir en liste", "ai.to_list": "Convertir en liste",
"ai.to_table": "Convertir en tableau", "ai.to_table": "Convertir en tableau",
"ai.tone_hint": "Change le ton du texte", "ai.tone_hint": "Change le ton du texte",
"ai.tool_call": "Outil : {name}",
"ai.tool_call_failed": "Outil {name} en échec",
"ai.toolbar_edit": "Éditer", "ai.toolbar_edit": "Éditer",
"ai.toolbar_generate": "Générer", "ai.toolbar_generate": "Générer",
"ai.toolbar_rewrite": "Réécrire", "ai.toolbar_rewrite": "Réécrire",
@@ -1212,6 +1217,7 @@
"help.assistant_markdown": "Réponses formatées : titres, listes, tableaux, citations et blocs de code.", "help.assistant_markdown": "Réponses formatées : titres, listes, tableaux, citations et blocs de code.",
"help.assistant_links": "Les fichiers et chemins cités sont des liens : cliquez sur un fichier pour l'ouvrir, sur un dossier pour le révéler dans l'arborescence.", "help.assistant_links": "Les fichiers et chemins cités sont des liens : cliquez sur un fichier pour l'ouvrir, sur un dossier pour le révéler dans l'arborescence.",
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.", "help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
"help.toolbar_section": "Barre d'outils AI", "help.toolbar_section": "Barre d'outils AI",
"help.tree_section": "Arborescence des vaults", "help.tree_section": "Arborescence des vaults",
"help.use_cases": "Cas d'usage", "help.use_cases": "Cas d'usage",
+15
View File
@@ -9222,6 +9222,21 @@ body.popup-mode .content-area {
background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; } 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:disabled { opacity: 0.6; cursor: default; }
.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); } .bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
/* Agent-mode toggle in the header. */
.bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); }
/* Tool-call trace shown above an assistant answer (agent mode). */
.bookslm-tool-trace { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; }
.bookslm-tool-line { font-size: 11px; color: var(--text-secondary); }
.bookslm-tool-line.failed { color: #f87171; }
/* Mutation confirmation card (two-step propose/apply). */
.bookslm-confirm { flex-wrap: wrap; }
.bookslm-confirm-diff { flex-basis: 100%; width: 100%; margin-top: 4px; }
.bookslm-diff-title { font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; }
.bookslm-diff { background: rgba(0,0,0,0.25); border-radius: 6px; padding: 8px;
overflow-x: auto; font-size: 12px; line-height: 1.4; max-height: 240px; margin: 0; }
.bookslm-diff-add { color: #4ade80; display: block; }
.bookslm-diff-del { color: #f87171; display: block; }
.bookslm-diff-ctx { color: var(--text-secondary); display: block; }
.bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; } .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); .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; } background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; }
+94
View File
@@ -513,6 +513,100 @@ async function main() {
assert.equal(b._messages[0].content, "Ancienne conversation"); assert.equal(b._messages[0].content, "Ancienne conversation");
}); });
// ── 14. Agent mode & confirmations (B5) ──
await test("agent mode toggle persists and switches the endpoint", async () => {
localStorage.clear();
const b = new BooksLM();
assert.equal(b._agentMode, false, "agent mode off by default");
b._toggleAgentMode();
assert.equal(b._agentMode, true);
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true");
const b2 = new BooksLM();
assert.equal(b2._agentMode, true, "persisted across instances");
let url = null;
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
b2._abortCtrl = null;
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent");
b2._agentMode = false;
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
localStorage.clear();
});
await test("header exposes the agent-mode toggle", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
const btn = panel.querySelector(".bookslm-btn-agent");
assert.ok(btn, "agent toggle button present");
assert.equal(btn.getAttribute("aria-pressed"), "false");
btn.click();
assert.equal(btn.getAttribute("aria-pressed"), "true");
panel.remove();
localStorage.clear();
});
await test("_lineDiff marks additions and deletions", () => {
const b = new BooksLM();
const diff = b._lineDiff("a\nb\nc", "a\nB\nc\nd");
const adds = diff.filter((l) => l.t === "+").map((l) => l.s);
const dels = diff.filter((l) => l.t === "-").map((l) => l.s);
assert.ok(dels.includes("b"), "removed line marked");
assert.ok(adds.includes("B"), "changed line marked as addition");
assert.ok(adds.includes("d"), "appended line marked");
});
await test("confirmation card resumes the agent run with confirm payload", async () => {
localStorage.clear();
const encoder = new TextEncoder();
let posted = null;
const sse =
'event: tool\ndata: {"name":"edit_file","ok":true,"arguments":{}}\n\n' +
'event: message\ndata: {"token":"Fait."}\n\n' +
"event: done\ndata: {}\n\n";
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/agent")) {
posted = JSON.parse(opts.body);
const chunks = [encoder.encode(sse)];
const reader = { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }) };
return { ok: true, status: 200, body: { getReader: () => reader } };
}
return { ok: true, status: 200, json: async () => ({ raw: "old content" }) };
};
const b = new BooksLM();
b._agentMode = true;
b._panel = b._render();
document.body.appendChild(b._panel);
const msg = {
role: "assistant",
content: "",
sources: [],
toolCalls: [],
confirmation: {
pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "new content" }, id: "c1" } },
messages: [{ role: "system", content: "s" }],
},
payload: { message: "edit", vault: "V", mode: "general", conversation_history: [] },
};
b._messages = [msg];
await b._applyConfirmation(msg);
assert.equal(msg.confirmation, null, "confirmation resolved");
assert.ok(posted, "resumed via /agent");
assert.equal(posted.confirm.error.tool, "edit_file");
assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]);
const cont = b._messages[b._messages.length - 1];
assert.equal(cont.content, "Fait.");
assert.equal(cont.toolCalls.length, 1, "tool trace captured");
b._panel.remove();
localStorage.clear();
});
// ── Summary ── // ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`); console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) { if (passCount !== testCount) {
+59
View File
@@ -186,6 +186,65 @@ class TestConfirmationAndLimits:
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
class TestConfirmationResume:
@pytest.mark.asyncio
async def test_resume_applies_pending_and_continues(self, monkeypatch):
seen = {}
def handler(ctx, params):
seen["ctx_confirmed"] = ctx.confirmed
return {"done": True}
_register(monkeypatch, "_write", handler, risk=ToolRisk.WRITE)
# First run pauses on the mutating tool.
llm1 = ScriptedLLM([LLMResponse(tool_calls=[ToolCall(id="1", name="_write", arguments={"x": 1})])])
paused = await run_agent([{"role": "user", "content": "write"}], ctx=_ctx(), llm=llm1)
assert paused.stopped == STOP_CONFIRMATION_REQUIRED
assert paused.pending["error"]["id"] == "1"
# Resume: the pending call is applied (one-shot confirm), then the loop
# continues and produces the final answer.
llm2 = ScriptedLLM([LLMResponse(content="applied")])
resumed = await run_agent(
[{"role": "user", "content": "write"}],
ctx=_ctx(),
llm=llm2,
resume_messages=paused.messages,
confirm_pending=paused.pending,
)
assert resumed.stopped == STOP_DONE
assert resumed.content == "applied"
assert len(resumed.tool_calls) == 1
assert resumed.tool_calls[0].ok is True
# The one-shot confirmation must not leak into the context.
assert seen["ctx_confirmed"] is False
# The tool result is fed back to the model on the resumed turn.
tool_msgs = [m for m in llm2.calls[0]["messages"] if m.get("role") == "tool"]
assert len(tool_msgs) == 1
assert tool_msgs[0]["tool_call_id"] == "1"
@pytest.mark.asyncio
async def test_resume_without_assistant_message_reconstructs_it(self, monkeypatch):
_register(monkeypatch, "_write", lambda ctx, params: {"done": True}, risk=ToolRisk.WRITE)
llm = ScriptedLLM([LLMResponse(content="ok")])
pending = {"error": {"tool": "_write", "arguments": {}, "id": "call_9"}}
result = await run_agent(
[{"role": "user", "content": "write"}],
ctx=_ctx(),
llm=llm,
resume_messages=[{"role": "user", "content": "write"}],
confirm_pending=pending,
)
assert result.stopped == STOP_DONE
assistant_tool_msgs = [
m for m in llm.calls[0]["messages"]
if m.get("role") == "assistant" and m.get("tool_calls")
]
assert len(assistant_tool_msgs) == 1
assert assistant_tool_msgs[0]["tool_calls"][0]["id"] == "call_9"
class TestAgentPermissions: class TestAgentPermissions:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_permission_denied_recorded(self, client): async def test_permission_denied_recorded(self, client):
+92
View File
@@ -199,3 +199,95 @@ class TestGeminiToolsConversion:
converted = _gemini_tools(TOOLS) converted = _gemini_tools(TOOLS)
assert converted[0]["functionDeclarations"][0]["name"] == "read_file" assert converted[0]["functionDeclarations"][0]["name"] == "read_file"
assert converted[0]["functionDeclarations"][0]["description"] == "Read a file" assert converted[0]["functionDeclarations"][0]["description"] == "Read a file"
# ═══════════════════════════════════════════════════════════════════
# Streaming (B4)
# ═══════════════════════════════════════════════════════════════════
class _StreamResponse:
"""Fake httpx streaming response yielding pre-scripted SSE lines."""
def __init__(self, lines, status=200):
self.lines = lines
self.status_code = status
def raise_for_status(self):
if self.status_code >= 400:
raise _http_error(self.status_code)
async def aiter_lines(self):
for line in self.lines:
yield line
class _FakeAsyncClient:
"""Minimal stand-in for ``httpx.AsyncClient`` used in stream mode."""
captured: dict = {}
response: _StreamResponse | None = None
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def stream(self, method, url, headers=None, json=None):
_FakeAsyncClient.captured = {"method": method, "url": url, "headers": headers, "json": json}
resp = _FakeAsyncClient.response
class _Ctx:
async def __aenter__(self_inner):
return resp
async def __aexit__(self_inner, *exc):
return False
return _Ctx()
class TestStreamCompletion:
@pytest.mark.asyncio
async def test_openai_stream_yields_deltas(self, monkeypatch):
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _StreamResponse([
'data: {"choices":[{"delta":{"content":"Bon"}}]}',
"",
'data: {"choices":[{"delta":{"content":"jour"}}]}',
"data: not-json",
"data: [DONE]",
])
chunks = [c async for c in ai_chat.stream_completion([{"role": "user", "content": "hi"}])]
assert "".join(chunks) == "Bonjour"
assert _FakeAsyncClient.captured["json"]["stream"] is True
@pytest.mark.asyncio
async def test_gemini_stream_yields_text(self, monkeypatch):
monkeypatch.setitem(ai_chat.PROVIDERS, "gemini", GEMINI_CFG)
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: GEMINI_CFG)
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _StreamResponse([
'data: {"candidates":[{"content":{"parts":[{"text":"sa"}]}}]}',
'data: {"candidates":[{"content":{"parts":[{"text":"lut"}]}}]}',
])
chunks = [c async for c in ai_chat.stream_completion([{"role": "user", "content": "hi"}])]
assert "".join(chunks) == "salut"
assert "streamGenerateContent" in _FakeAsyncClient.captured["url"]
@pytest.mark.asyncio
async def test_stream_raises_on_http_error(self, monkeypatch):
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _StreamResponse([], status=500)
with pytest.raises(httpx.HTTPStatusError):
async for _ in ai_chat.stream_completion([{"role": "user", "content": "hi"}]):
pass
+4 -2
View File
@@ -621,13 +621,15 @@ class TestResolveSafePath:
def test_resolve_traversal_attempt(self): def test_resolve_traversal_attempt(self):
from backend.main import _resolve_safe_path from backend.main import _resolve_safe_path
from backend.services.errors import ServiceError
from pathlib import Path from pathlib import Path
root = Path("/vaults/TestVault") root = Path("/vaults/TestVault")
try: try:
_resolve_safe_path(root, "../../etc/passwd") _resolve_safe_path(root, "../../etc/passwd")
assert False, "Should have raised" assert False, "Should have raised"
except Exception as e: except ServiceError as e:
assert "traversal" in str(e).lower() or "403" in str(e) assert e.status == 403
assert e.code == "path_outside_vault"
class TestBackupFile: class TestBackupFile:
+62
View File
@@ -823,3 +823,65 @@ class TestBooksLMAgentEndpoint:
assert resp.status_code == 200 assert resp.status_code == 200
assert "event: confirmation" in resp.text assert "event: confirmation" in resp.text
assert "_agent_write" in resp.text assert "_agent_write" in resp.text
def test_agent_confirmation_resume(self, bookslm_client, monkeypatch):
import re
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall
from backend.tools import registry
from backend.tools.api import ToolRisk
from backend.tools.registry import ToolSpec
from backend.tools.schemas import ListVaultsInput
executed = {}
def handler(ctx, params):
executed["ran"] = True
return {"ok": True}
spec = ToolSpec(
name="_agent_write_resume",
description="write for tests",
input_model=ListVaultsInput,
handler=handler,
risk=ToolRisk.WRITE,
)
monkeypatch.setitem(registry._REGISTRY, "_agent_write_resume", spec)
responses = [LLMResponse(tool_calls=[ToolCall(id="c1", name="_agent_write_resume", arguments={})])]
async def fake_chat_completion(messages, **kwargs):
return responses.pop(0)
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
payload = {"vault": "TestVault", "directory": "", "message": "crée un fichier", "mode": "directory"}
resp = bookslm_client.post(
"/api/ai/bookslm/agent",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert "event: confirmation" in resp.text
assert "_agent_write_resume" in resp.text
assert executed.get("ran") is not True
match = re.search(r"event: confirmation\ndata: (.*)", resp.text)
assert match, resp.text
confirmation = json.loads(match.group(1))
assert confirmation["pending"]["error"]["tool"] == "_agent_write_resume"
# Resume: apply the pending call, then the model answers.
responses.append(LLMResponse(content="C'est fait."))
resume_payload = dict(payload, confirm=confirmation["pending"], confirm_messages=confirmation["messages"])
resp2 = bookslm_client.post(
"/api/ai/bookslm/agent",
json=resume_payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp2.status_code == 200
assert executed.get("ran") is True
assert "C'est fait." in resp2.text
assert "event: message" in resp2.text
+154
View File
@@ -0,0 +1,154 @@
# tests/test_services.py — Unit tests for the shared business-logic services (A2)
"""Tests for backend.services: vaults, files, search, and error mapping.
The index-dependent tests reuse the ``client`` fixture from conftest, which
builds the in-memory index for a ``TestVault``.
"""
from pathlib import Path
import pytest
from backend.services.errors import ServiceError
from backend.services.files import read_file_text, read_raw_file
from backend.services.search import list_tags, search_vaults
from backend.services.vaults import browse_directory, list_accessible_vaults
ADMIN = {"username": "admin", "role": "admin", "vaults": ["*"]}
LIMITED = {"username": "limited", "vaults": ["OtherVault"]}
# ═══════════════════════════════════════════════════════════════════
# Vaults
# ═══════════════════════════════════════════════════════════════════
class TestListAccessibleVaults:
def test_admin_sees_vault(self, client):
names = {v["name"] for v in list_accessible_vaults(ADMIN)}
assert "TestVault" in names
def test_limited_user_sees_none(self, client):
assert list_accessible_vaults(LIMITED) == []
def test_metadata_fields(self, client):
vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault")
assert set(vault) == {"name", "file_count", "tag_count", "type"}
assert vault["file_count"] >= 1
class TestBrowseDirectory:
def test_root_lists_children(self, client):
data = browse_directory("TestVault", "")
names = {i["name"] for i in data["items"]}
assert "note1.md" in names
assert "Projets" in names
def test_missing_path_raises_not_found(self, client):
with pytest.raises(ServiceError) as exc:
browse_directory("TestVault", "nope")
assert exc.value.code == "not_found"
def test_unknown_vault_raises_not_found(self, client):
with pytest.raises(ServiceError) as exc:
browse_directory("MissingVault", "")
assert exc.value.code == "not_found"
# ═══════════════════════════════════════════════════════════════════
# Files
# ═══════════════════════════════════════════════════════════════════
class TestReadFile:
def test_read_text_redacts_secrets(self, client):
from backend.indexer import get_vault_data
root = Path(get_vault_data("TestVault")["path"])
secret = root / "svc_secret.md"
fake_jwt = "eyJ" + "a" * 30 + "." + "b" * 30 + "." + "c" * 30
secret.write_text(f"token: {fake_jwt}\n", encoding="utf-8")
try:
data = read_file_text("TestVault", "svc_secret.md")
assert "[JWT MASQUÉ]" in data["content"]
finally:
secret.unlink()
def test_read_text_too_large(self, client):
from backend.indexer import get_vault_data
root = Path(get_vault_data("TestVault")["path"])
big = root / "svc_big.txt"
big.write_text("x" * 1000, encoding="utf-8")
try:
with pytest.raises(ServiceError) as exc:
read_file_text("TestVault", "svc_big.txt", max_bytes=10)
assert exc.value.code == "file_too_large"
finally:
big.unlink()
def test_read_raw_no_redaction(self, client):
data = read_raw_file("TestVault", "note1.md")
assert "Python" in data["raw"]
def test_read_missing_raises_not_found(self, client):
with pytest.raises(ServiceError) as exc:
read_raw_file("TestVault", "missing.md")
assert exc.value.code == "not_found"
def test_traversal_is_rejected(self, client):
with pytest.raises(ServiceError) as exc:
read_raw_file("TestVault", "../../etc/passwd")
assert exc.value.code == "path_outside_vault"
# ═══════════════════════════════════════════════════════════════════
# Search
# ═══════════════════════════════════════════════════════════════════
class TestSearchService:
def test_pagination(self, client):
data = search_vaults("Python", limit=1, offset=0)
assert data["limit"] == 1
assert len(data["results"]) <= 1
assert data["total"] >= 1
def test_returns_api_shape(self, client):
data = search_vaults("Python")
assert set(data) >= {"query", "vault_filter", "tag_filter", "count", "total", "offset", "limit", "results"}
def test_tags(self, client):
tags = list_tags("TestVault")
assert "python" in tags
# ═══════════════════════════════════════════════════════════════════
# Service → Tool error mapping
# ═══════════════════════════════════════════════════════════════════
class TestServiceErrorMapping:
def test_not_found_maps_to_tool_not_found(self, client):
from backend.tools.api import ToolContext, ToolNotFoundError, call_tool
ctx = ToolContext(user=ADMIN, audit_enabled=False)
with pytest.raises(ToolNotFoundError):
call_tool("read_file", ctx, {"vault": "TestVault", "path": "nope.md"})
def test_file_too_large_preserves_code(self, client, monkeypatch):
from backend.indexer import get_vault_data
from backend.tools import service as service_mod
from backend.tools.api import ToolContext, ToolError, call_tool
monkeypatch.setattr(service_mod, "TOOL_MAX_READ_BYTES", 10)
root = Path(get_vault_data("TestVault")["path"])
big = root / "svc_tool_big.txt"
big.write_text("x" * 1000, encoding="utf-8")
try:
ctx = ToolContext(user=ADMIN, audit_enabled=False)
with pytest.raises(ToolError) as exc:
call_tool("read_file", ctx, {"vault": "TestVault", "path": "svc_tool_big.txt"})
assert exc.value.code == "file_too_large"
finally:
big.unlink()