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
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:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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])]
|
||||||
|
|||||||
@@ -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
@@ -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** |
|
||||||
|
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user