diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cba41..3ff6f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,9 +52,16 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **discret** au-dessus de chaque réponse rapporte le fournisseur et le modèle réellement utilisés (SSE `provider`/`model`) ; une **barre d'actions** au survol sous chaque bloc : « Copier » (texte brut + toast) et, pour les réponses, - « Ajouter » (insertion dans le document ouvert dans l'éditeur Forge). Fiche : + « Ajouter » (insertion dans le document ouvert dans l'éditeur Forge). + Complément : la section « N étapes » affiche désormais des libellés humains + produits par le backend (`backend/tools/labels.py`, événements SSE en direct + pendant l'exécution + ligne « Réflexion »), et deux nouveaux outils web + principaux sont disponibles en mode agent : `web_search` (SearXNG + auto-hébergé) et `fetch_url` (page publique → texte, garde SSRF). Le reste + des catégories d'outils Notion est documenté pour le futur dans la fiche. + Fiche : [docs/features/ai-assistant-conversation-ux.md](./docs/features/ai-assistant-conversation-ux.md) - Tests : `tests/frontend/ai.test.mjs` (74). + Tests : `tests/frontend/ai.test.mjs` (76), `tests/test_tool_labels.py` (8), `tests/test_web_tools.py` (8). ### Corrigé diff --git a/backend/agent/loop.py b/backend/agent/loop.py index 0473fde..1dfd607 100644 --- a/backend/agent/loop.py +++ b/backend/agent/loop.py @@ -30,6 +30,7 @@ from backend.tools.api import ( call_tool, get_tool_schemas, ) +from backend.tools.labels import thought_step_label, tool_step_label logger = logging.getLogger("obsigate.agent.loop") @@ -54,6 +55,9 @@ class ToolCallRecord: arguments: dict[str, Any] ok: bool result: Any + # Human-readable « step » label for the Notion-style UI + # ({key, params} — see backend.tools.labels). + step: dict[str, Any] = field(default_factory=dict) @dataclass @@ -63,6 +67,9 @@ class AgentResult: content: str = "" messages: list[dict[str, Any]] = field(default_factory=list) tool_calls: list[ToolCallRecord] = field(default_factory=list) + # Ordered Notion-style step descriptors ({key, params}); tool steps and + # intermediate reasoning notes interleaved by execution order. + steps: list[dict[str, Any]] = field(default_factory=list) iterations: int = 0 stopped: str = STOP_DONE pending: dict[str, Any] | None = None @@ -142,7 +149,10 @@ def _execute_confirmed( payload = e.to_dict() ok = False - record = ToolCallRecord(name=name, arguments=arguments, ok=ok, result=payload) + record = ToolCallRecord( + name=name, arguments=arguments, ok=ok, result=payload, + step=tool_step_label(name, arguments), + ) executed.append(record) if on_tool_call is not None: on_tool_call(record) @@ -164,6 +174,7 @@ async def run_agent( max_iterations: int = DEFAULT_MAX_ITERATIONS, max_tool_calls: int | None = None, on_tool_call: Callable[[ToolCallRecord], None] | None = None, + on_thought: Callable[[dict[str, Any]], None] | None = None, resume_messages: list[dict[str, Any]] | None = None, confirm_pending: dict[str, Any] | None = None, ) -> AgentResult: @@ -195,6 +206,15 @@ async def run_agent( if tools is None: tools = get_tool_schemas(scope=ToolScope.IN_APP) quota = DEFAULT_MAX_TOOL_CALLS if max_tool_calls is None else max_tool_calls + steps: list[dict[str, Any]] = [] + + def _emit_note(text: str) -> None: + """Record an intermediate reasoning note as a visible step.""" + note = thought_step_label(text) + if note["params"]["value"]: + steps.append(note) + if on_thought is not None: + on_thought(note) convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)] executed: list[ToolCallRecord] = [] @@ -205,6 +225,7 @@ async def run_agent( content="", messages=convo, tool_calls=executed, + steps=steps, iterations=0, stopped=STOP_QUOTA_EXCEEDED, ) @@ -218,10 +239,13 @@ async def run_agent( content=response.content or "", messages=convo, tool_calls=executed, + steps=steps, iterations=iteration, stopped=STOP_DONE, ) + # Intermediate reasoning shown alongside tool calls → a "thought" step. + _emit_note(response.content or "") convo.append(_assistant_tool_message(response.content, response.tool_calls)) for call in response.tool_calls: @@ -231,6 +255,7 @@ async def run_agent( content=response.content or "", messages=convo, tool_calls=executed, + steps=steps, iterations=iteration, stopped=STOP_QUOTA_EXCEEDED, ) @@ -247,6 +272,7 @@ async def run_agent( content=response.content or "", messages=convo, tool_calls=executed, + steps=steps, iterations=iteration, stopped=STOP_CONFIRMATION_REQUIRED, pending=pending, @@ -255,8 +281,12 @@ async def run_agent( payload = e.to_dict() ok = False - record = ToolCallRecord(name=call.name, arguments=call.arguments, ok=ok, result=payload) + record = ToolCallRecord( + name=call.name, arguments=call.arguments, ok=ok, result=payload, + step=tool_step_label(call.name, call.arguments), + ) executed.append(record) + steps.append(record.step) if on_tool_call is not None: on_tool_call(record) @@ -272,6 +302,7 @@ async def run_agent( content="", messages=convo, tool_calls=executed, + steps=steps, iterations=max_iterations, stopped=STOP_MAX_ITERATIONS, ) diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index e48f019..6e933c9 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -317,6 +317,21 @@ def _resolve_provider_name(requested: str | None) -> str | None: return None +def _tool_event_sse(rec) -> str: + """Serialize one executed tool call as an SSE ``tool`` event.""" + payload = json.dumps( + {"name": rec.name, "ok": rec.ok, "arguments": rec.arguments, "step": rec.step}, + ensure_ascii=False, + ) + return f"event: tool\ndata: {payload}\n\n" + + +def _thought_event_sse(note: dict) -> str: + """Serialize one intermediate reasoning note as an SSE ``step`` event.""" + payload = json.dumps({"step": note}, ensure_ascii=False) + return f"event: step\ndata: {payload}\n\n" + + # ── Endpoints ── @@ -456,6 +471,8 @@ async def api_bookslm_agent( ) async def generate_sse(): + import asyncio + try: cfg_name = _resolve_provider_name(req.provider) if cfg_name is None: @@ -466,20 +483,43 @@ async def api_bookslm_agent( yield f"event: error\ndata: {error_data}\n\n" return - result = await run_agent( + # Stream tool events live: each executed step is pushed on the + # queue by the loop callback and emitted as soon as it happens, + # so the UI can grow its « N steps » block while thinking. + queue: asyncio.Queue = asyncio.Queue() + + def _on_tool(rec) -> None: + queue.put_nowait(("tool", rec)) + + run_task = asyncio.create_task(run_agent( messages, ctx=ctx, llm=_llm, resume_messages=req.confirm_messages, confirm_pending=req.confirm, - ) + on_tool_call=_on_tool, + on_thought=lambda note: queue.put_nowait(("thought", note)), + )) - for rec in result.tool_calls: - payload = json.dumps( - {"name": rec.name, "ok": rec.ok, "arguments": rec.arguments}, - ensure_ascii=False, - ) - yield f"event: tool\ndata: {payload}\n\n" + # Drain every completed step as soon as it lands, while the agent + # keeps running in the background. If the client disconnects, the + # generator is cancelled: release the run so it cannot orphan. + try: + while True: + try: + kind, item = await asyncio.wait_for(queue.get(), timeout=0.25) + except asyncio.TimeoutError: + if run_task.done(): + break + continue + yield _tool_event_sse(item) if kind == "tool" else _thought_event_sse(item) + while not queue.empty(): + kind, item = queue.get_nowait() + yield _tool_event_sse(item) if kind == "tool" else _thought_event_sse(item) + result = run_task.result() + finally: + if not run_task.done(): + run_task.cancel() if result.stopped == "confirmation_required": pending = json.dumps( diff --git a/backend/tools/api.py b/backend/tools/api.py index 6061c7f..3944572 100644 --- a/backend/tools/api.py +++ b/backend/tools/api.py @@ -10,6 +10,7 @@ which ``.gitignore`` excludes via ``_*.py``), hence this explicit facade. """ from backend.tools import service as _service # noqa: F401 (registers tools) +from backend.tools import web as _web # noqa: F401 (registers web tools) from backend.tools.context import ( ToolConfirmationRequired, ToolContext, diff --git a/backend/tools/labels.py b/backend/tools/labels.py new file mode 100644 index 0000000..4507080 --- /dev/null +++ b/backend/tools/labels.py @@ -0,0 +1,89 @@ +"""Human-readable labels for tool calls (Notion-style “steps” section). + +The assistant's conversation window shows a collapsible « N steps » block +above each answer. Raw tool names (``search_fulltext``) are developer-centric; +this module maps every registered tool to an i18n message key plus the +argument worth surfacing (path, query, …), so the UI can render sentences like +« Recherche dans le vault : pizza » or « Fichier lu : notes/x.md ». + +The label KEY is emitted with each ``tool`` SSE event; the client resolves it +against its locale dictionaries (``ai.step.``). Unknown tools fall back to +a generic key with the raw name prettified, so a new tool never breaks the UI. +""" + +from __future__ import annotations + +from typing import Any + +# tool name -> (message-key, primary argument name or None) +# NOTE: argument names MUST match the pydantic input models in +# backend/tools/schemas.py (verified by tests/test_tool_labels.py). +_STEP_LABELS: dict[str, tuple[str, str | None]] = { + "list_vaults": ("vaults", None), + "list_directory": ("directory", "path"), + "list_all_files": ("files", "dir"), + "read_file": ("file_read", "path"), + "read_file_raw": ("file_read", "path"), + "get_backlinks": ("backlinks", "path"), + "list_backups": ("backups", "path"), + "diff_backup": ("backup_diff", "path"), + "get_graph": ("graph", None), + "search_fulltext": ("search", "q"), + "search_advanced": ("search", "q"), + "search_paths": ("search_paths", "q"), + "list_tags": ("tags", None), + "suggest_tags": ("tags_suggest", "q"), + "list_recent": ("recent", None), + "create_file": ("file_create", "path"), + "create_directory": ("dir_create", "path"), + "edit_file": ("file_edit", "path"), + "append_to_file": ("file_append", "path"), + "rename_file": ("file_rename", "path"), + "rename_directory": ("dir_rename", "path"), + "move_path": ("move", "source_path"), + "replace_in_files": ("replace", "find"), + "delete_file": ("file_delete", "path"), + "delete_directory": ("dir_delete", "path"), + "restore_backup": ("backup_restore", "path"), + "web_search": ("web_search", "query"), + "fetch_url": ("fetch_url", "url"), +} + +GENERIC_KEY = "generic" + + +def _prettify(name: str) -> str: + return name.replace("_", " ").strip() + + +def _primary_value(arguments: dict[str, Any], arg: str | None) -> str | None: + if not arg: + return None + value = arguments.get(arg) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def tool_step_label(name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + """Return ``{key, params}`` describing one tool call in human terms. + + ``key`` resolves client-side to ``ai.step.``; ``params`` carries the + optional ``{value}`` placeholder (path, query, …). For an unknown tool the + generic key is used with the prettified name as the value. + """ + arguments = arguments or {} + key, arg = _STEP_LABELS.get(name, (GENERIC_KEY, None)) + if key == GENERIC_KEY: + return {"key": GENERIC_KEY, "params": {"tool": _prettify(name)}} + value = _primary_value(arguments, arg) + params: dict[str, str] = {} + if value is not None: + params["value"] = value + return {"key": key, "params": params} + + +def thought_step_label(text: str) -> dict[str, Any]: + """Step descriptor for one intermediate reasoning note of the model.""" + cleaned = " ".join((text or "").split()) + return {"key": "thought", "params": {"value": cleaned[:400]}} diff --git a/backend/tools/schemas.py b/backend/tools/schemas.py index f233255..601b93f 100644 --- a/backend/tools/schemas.py +++ b/backend/tools/schemas.py @@ -237,6 +237,22 @@ class RestoreBackupInput(BaseModel): version: int = Field(..., description="Backup timestamp to restore") +class WebSearchInput(BaseModel): + """Search the public web through the configured meta-search engine.""" + + query: str = Field(..., description="Search terms") + max_results: int = Field(5, ge=1, le=10, description="Number of results to return") + category: str = Field("", description="Optional engine category: general, news, it, science") + language: str = Field("", description="Optional language code, e.g. 'fr'") + page: int = Field(1, ge=1, le=10, description="Result page number") + + +class FetchUrlInput(BaseModel): + """Fetch one public web page and return its readable text.""" + + url: str = Field(..., description="Absolute http(s) URL of a public page") + + class ToolResult(BaseModel): """Uniform result returned by :func:`backend.tools.registry.call_tool`.""" diff --git a/backend/tools/web.py b/backend/tools/web.py new file mode 100644 index 0000000..aa48344 --- /dev/null +++ b/backend/tools/web.py @@ -0,0 +1,217 @@ +"""Web tools for the assistant (Notion-style "research" capabilities). + +Phase 1 of the documented web-toolset roadmap: + +* ``web_search`` — query the self-hosted SearXNG instance (no API key). +* ``fetch_url`` — retrieve a public web page and return readable text. + +Both are READ-risk tools (no confirmation), rate-limited through the shared +registry, SSRF-guarded (scheme + private-address rejection), and size-capped. + +Configuration (environment): +* ``OBSIGATE_SEARXNG_URL`` — defaults to https://search.dracodev.net +* ``OBSIGATE_WEB_TIMEOUT`` — seconds, default 10 +""" + +from __future__ import annotations + +import html as html_lib +import ipaddress +import logging +import os +import re +import socket +from typing import Any +from urllib.parse import urlparse + +import httpx + +from backend.tools.context import ToolError, ToolRisk, ToolScope +from backend.tools.registry import tool +from backend.tools.schemas import FetchUrlInput, WebSearchInput + +logger = logging.getLogger("obsigate.tools.web") + +SEARXNG_URL = os.environ.get("OBSIGATE_SEARXNG_URL", "https://search.dracodev.net") +WEB_TIMEOUT = float(os.environ.get("OBSIGATE_WEB_TIMEOUT", "10")) +USER_AGENT = "ObsiGateAssistant/1.0 (+self-hosted vault AI)" +MAX_FETCH_BYTES = 1_500_000 +MAX_TEXT_CHARS = 20_000 + +_BLOCKED_TAGS_RE = re.compile( + r"<(script|style|noscript|template|svg)\b.*?", re.IGNORECASE | re.DOTALL +) +_TAG_RE = re.compile(r"<[^>]+>") +_BLOCK_SPLIT_RE = re.compile( + r"]*>", + re.IGNORECASE, +) + + +class SSRFError(ToolError): + """Raised for a URL whose host is private/loopback or scheme unsupported.""" + + +def _assert_public_http_url(url: str) -> str: + """Reject non-http(s) schemes and private/loopback/link-local targets.""" + try: + parsed = urlparse(url) + except ValueError as e: + raise SSRFError("URL invalide", code="invalid_url") from e + if parsed.scheme not in ("http", "https"): + raise SSRFError("Seuls les schémas http/https sont autorisés", code="invalid_scheme") + host = parsed.hostname + if not host: + raise SSRFError("URL sans hôte", code="invalid_url") + # Resolve the host so DNS-rebinding to internal IPs is also caught. + try: + infos = socket.getaddrinfo(host, None) + except socket.gaierror as e: + raise SSRFError(f"Hôte introuvable: {host}", code="dns_error") from e + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ): + raise SSRFError("Accès aux adresses internes interdit", code="ssrf_blocked") + return url + + +def _html_to_text(raw: str) -> str: + """Cheap HTML → readable text: strip scripts/styles, tags, then compress. + + Comments (which may carry script-like payloads) are removed first. + """ + text = re.sub(r"", " ", raw, flags=re.DOTALL) + text = _BLOCKED_TAGS_RE.sub(" ", text) + # Keep block boundaries as newlines before dropping the remaining tags. + text = _BLOCK_SPLIT_RE.sub("\n", text) + text = _TAG_RE.sub("", text) + text = html_lib.unescape(text) + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r" ?\n ?", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +@tool( + name="web_search", + description=( + "Search the public web for current information and return ranked results " + "(title, url, snippet). Use for facts outside the vault: weather, news, " + "documentation, versions, prices, anything that needs live sources." + ), + input_model=WebSearchInput, + risk=ToolRisk.READ, + scopes=(ToolScope.IN_APP,), +) +def web_search(ctx, params: WebSearchInput) -> dict[str, Any]: + """Query the self-hosted SearXNG instance and return trimmed results.""" + query = params.query.strip() + if not query: + raise ToolError("Requête vide", code="invalid_arguments") + url = SEARXNG_URL.rstrip("/") + "/search" + try: + resp = httpx.get( + url, + params={ + "q": query, + "format": "json", + "categories": params.category or "general", + "pageno": max(1, params.page), + **({"language": params.language} if params.language else {}), + "safesearch": "1", + }, + headers={"User-Agent": USER_AGENT}, + timeout=WEB_TIMEOUT, + follow_redirects=False, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as e: + logger.warning("web_search failed: %s", e) + raise ToolError( + "Le moteur de recherche web est momentanément indisponible.", + code="web_search_unavailable", + ) from e + results: list[dict[str, Any]] = [] + for item in (data.get("results") or [])[: params.max_results]: + results.append( + { + "title": (item.get("title") or "")[:300], + "url": item.get("url") or "", + "snippet": (item.get("content") or "")[:600], + "published": item.get("publishedDate"), + "score": item.get("score"), + } + ) + return { + "query": query, + "engine": "searxng", + "results": results, + "count": len(results), + } + + +@tool( + name="fetch_url", + description=( + "Fetch a public web page (http/https) and return its readable text. " + "Use after web_search to read a promising result in detail. HTML is " + "converted to plain text; binary pages are rejected." + ), + input_model=FetchUrlInput, + risk=ToolRisk.READ, + scopes=(ToolScope.IN_APP,), +) +def fetch_url(ctx, params: FetchUrlInput) -> dict[str, Any]: + """Retrieve one page, guard against SSRF, and extract its text.""" + url = _assert_public_http_url(params.url.strip()) + try: + # Follow redirects manually so every hop is re-checked against the + # private-address SSRF guard (a public page can redirect to 127.0.0.1). + resp = None + for _hop in range(5): + resp = httpx.get( + url, + headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml,*/*"}, + timeout=WEB_TIMEOUT, + follow_redirects=False, + ) + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("location") or "" + if not location: + break + url = str(httpx.URL(url).join(location)) + url = _assert_public_http_url(url) + continue + break + assert resp is not None + resp.raise_for_status() + except SSRFError: + raise + except httpx.HTTPError as e: + logger.warning("fetch_url failed for %s: %s", url, e) + raise ToolError("Impossible de récupérer la page.", code="fetch_unavailable") from e + ctype = (resp.headers.get("content-type") or "").lower() + if not any(t in ctype for t in ("html", "xml", "text", "json", "markdown")): + raise ToolError( + f"Type de contenu non pris en charge: {ctype.split(';')[0] or 'inconnu'}", + code="unsupported_content_type", + ) + raw = (resp.content[:MAX_FETCH_BYTES]).decode(resp.encoding or "utf-8", errors="replace") + title_match = re.search(r"]*>(.*?)", raw, re.IGNORECASE | re.DOTALL) + title = html_lib.unescape(title_match.group(1)).strip()[:300] if title_match else "" + text = _html_to_text(raw)[:MAX_TEXT_CHARS] + return { + "url": str(resp.url), + "status": resp.status_code, + "title": title, + "text": text, + "truncated": len(raw) > MAX_TEXT_CHARS, + } diff --git a/docs/features/ai-assistant-conversation-ux.md b/docs/features/ai-assistant-conversation-ux.md index 4e1cb5d..35514f5 100644 --- a/docs/features/ai-assistant-conversation-ux.md +++ b/docs/features/ai-assistant-conversation-ux.md @@ -80,4 +80,40 @@ - L'ancien épinglage flex `order: -1` (première itération de #91) est **remplacé** : l'ordre est redevenu chronologique, l'ancrage est fait par défilement explicite. - Les pouces haut/bas Notion ne sont **pas** repris : aucun signal n'existe - côté backend ; « Ajouter » (insertion éditeur réelle) les remplace utilement. \ No newline at end of file + côté backend ; « Ajouter » (insertion éditeur réelle) les remplace utilement. + +## G. Section « steps » enrichie + outils web (complément #91) — ✅ livré + - [x] **G1.** Chaque étape est décrite par le **backend** (`backend/tools/labels.py`) : + événement SSE `tool` avec `step {key, params}` ; le frontend résout + `ai.step.` (FR/EN) → phrases humaines : « Recherche dans le vault : pizza », + « Fichier lu : notes/a.md », etc. Outil inconnu → libellé générique (jamais cassé). + - [x] **G2.** Événements en **direct** : les steps sont poussés sur une file + `asyncio.Queue` par la boucle agent et émis dès leur exécution — le bloc + « N étapes » grandit pendant que l'assistant travaille (plus de liste fin de run). + - [x] **G3.** Ligne « Réflexion » (`ai.step.thought`) : quand le modèle émet un + texte intermédiaire avec ses appels d'outils, il est publié comme event SSE + `step` et affiché dans la liste (`. thought` Notion). + - [x] **G4.** Nouveaux outils principaux côté **web** (`backend/tools/web.py`, + risques READ, scope IN_APP, SSRF-guard + limite de taille) : + `web_search` (SearXNG auto-hébergé, configurable via `OBSIGATE_SEARXNG_URL`) + et `fetch_url` (lecture d'une page publique, HTML → texte). + Steps affichés : « Recherche sur le web : … » / « Page web consultée : … ». + +### Outils restants — documentés pour le futur (hors #91) +La catégorie Notion « étapes » peut s'étendre ; chaque futur outil devra être un +tool du registre (`@tool`) + un libellé dans `labels.py` + deux clés i18n. +Priorités proposées (à transformer en items `#NN` quand implémentés) : + - **Créer/supprimer un fichier depuis une réponse** : déjà couvert par les cartes + d'action `create_file` / l'outil `create_file` — exposer une action « Créer la + note » dans la barre d'actions (post-traitement du texte copié). + - **Insérer dans le document courant** : l'équivalent « Ajouter » côté assistant + est livré (G) ; l'export vers une sélection précise de l'éditeur reste possible. + - **Convertir un format / tableur / document** : outils `convert_format`, + `create_spreadsheet` (csv/xlsx via backend) — risque WRITE (confirmation). + - **Calendrier / tâches** : intégrer l'API existante `n8n-automation` ou un + serveur MCP externe (la porte MCP est déjà ouverte côté ObsiGate). + - **Courriel / messagerie** : via le serveur MCP externe (Gmail/IMPT SMTP) — + jamais de clé en dur : passer par Infisical. + - **Sources connectées (Slack, GitHub, Drive)** : uniquement par **MCP externe** + (`docs/features/ai-tools-mcp.md`), pas d'outils natifs — l'attaque surface + reste dans le registre + rate-limit + audit. \ No newline at end of file diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 2f47dea..a2efed6 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -2058,22 +2058,35 @@ class BooksLM { // ── Tool activity & confirmations (agent mode) ────────────────────── - /** Agent tool calls collapse into a discreet “N steps” line (Notion-style). */ - _renderToolActivity(toolCalls) { - const wrap = document.createElement('details'); - wrap.className = 'bookslm-tool-trace'; - const summary = document.createElement('summary'); - summary.textContent = t('ai.steps_count', { count: toolCalls.length }); - wrap.appendChild(summary); - 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); + /** Human one-liner for a recorded step (backend label key or legacy name). */ + _stepText(call) { + const step = call.step; + if (step && step.key) { + const key = `ai.step.${step.key}`; + const label = t(key, step.params || {}); + if (label && label !== key) return label; + } + return t('ai.tool_call', { name: call.name }); + } + + /** Agent tool calls collapse into a discreet “N steps” line (Notion-style). */ + _renderToolActivity(toolCalls) { + const wrap = document.createElement('details'); + wrap.className = 'bookslm-tool-trace'; + const summary = document.createElement('summary'); + const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count'; + summary.textContent = t(countKey, { count: toolCalls.length }); + wrap.appendChild(summary); + for (const call of toolCalls) { + const line = document.createElement('div'); + line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : ''); + line.textContent = `${call.ok === false ? '⚠' : '·'} ${this._stepText(call)}`; + if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name }); + else if (call.step && call.step.params && call.step.params.value) line.title = call.step.params.value; + wrap.appendChild(line); + } + return wrap; } - return wrap; - } _renderConfirmationCard(msg) { const conf = msg.confirmation; @@ -2393,10 +2406,22 @@ class BooksLM { } if (currentEvent === 'tool') { - assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} }); + assistantMsg.toolCalls.push({ + name: data.name, + ok: data.ok !== false, + arguments: data.arguments || {}, + step: data.step || null, + }); this._setActivity('working', t('ai.activity_tool', { name: data.name })); return; } + // Intermediate reasoning note streamed by the agent (Notion-style + // ". thought" line inside the steps block). + if (currentEvent === 'step' && data.step) { + assistantMsg.toolCalls.push({ name: '', ok: true, arguments: {}, step: data.step }); + this._setActivity('working', t('ai.activity_thinking')); + return; + } if (currentEvent === 'confirmation') { assistantMsg.confirmation = { pending: data.pending || data, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 1d8e238..d049353 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1780,7 +1780,37 @@ "bookslm.insert_hint": "Append the answer to the document open in the editor", "bookslm.inserted": "Answer added to the document", "bookslm.insert_no_editor": "No document open in the editor", - "ai.steps_count": "{count} steps", + "ai.steps_count": "{count} step", + "ai.steps_count_plural": "{count} steps", + "ai.activity_thinking": "Thinking…", + "ai.step.thought": "Thought: {value}", + "ai.step.backlinks": "Analyzed backlinks", + "ai.step.backup_diff": "Compared backup: {value}", + "ai.step.backup_restore": "Restored backup: {value}", + "ai.step.backups": "Listed backups", + "ai.step.dir_create": "Proposed directory: {value}", + "ai.step.dir_delete": "Deleted directory: {value}", + "ai.step.dir_rename": "Renamed directory: {value}", + "ai.step.directory": "Explored directory: {value}", + "ai.step.file_append": "Appended to file: {value}", + "ai.step.file_create": "Proposed file: {value}", + "ai.step.file_delete": "Deleted file: {value}", + "ai.step.file_edit": "Edited file: {value}", + "ai.step.file_read": "Read file: {value}", + "ai.step.file_rename": "Renamed file: {value}", + "ai.step.files": "Listed files", + "ai.step.generic": "Used tool: {tool}", + "ai.step.graph": "Consulted the vault graph", + "ai.step.move": "Moved: {value}", + "ai.step.recent": "Listed recent files", + "ai.step.replace": "Replacements: {value}", + "ai.step.search": "Searched the vault: {value}", + "ai.step.search_paths": "Searched paths: {value}", + "ai.step.tags": "Listed tags", + "ai.step.tags_suggest": "Suggested tags for {value}", + "ai.step.vaults": "Listed the vaults", + "ai.step.fetch_url": "Opened a web page: {value}", + "ai.step.web_search": "Searched the web: {value}", "bookslm.copied": "Copied to clipboard", "bookslm.error": "AI service error", "bookslm.regenerate": "Regenerate", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 85feacf..9e84935 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1780,7 +1780,37 @@ "bookslm.insert_hint": "Ajouter la réponse au document ouvert dans l'éditeur", "bookslm.inserted": "Réponse ajoutée au document", "bookslm.insert_no_editor": "Aucun document ouvert dans l'éditeur", - "ai.steps_count": "{count} étapes", + "ai.steps_count": "{count} étape", + "ai.steps_count_plural": "{count} étapes", + "ai.activity_thinking": "Réflexion…", + "ai.step.thought": "Réflexion : {value}", + "ai.step.backlinks": "Backlinks analysés", + "ai.step.backup_diff": "Comparaison de sauvegarde : {value}", + "ai.step.backup_restore": "Sauvegarde restaurée : {value}", + "ai.step.backups": "Sauvegardes consultées", + "ai.step.dir_create": "Dossier proposé : {value}", + "ai.step.dir_delete": "Dossier supprimé : {value}", + "ai.step.dir_rename": "Dossier renommé : {value}", + "ai.step.directory": "Répertoire exploré : {value}", + "ai.step.file_append": "Fichier complété : {value}", + "ai.step.file_create": "Fichier proposé : {value}", + "ai.step.file_delete": "Fichier supprimé : {value}", + "ai.step.file_edit": "Fichier modifié : {value}", + "ai.step.file_read": "Fichier lu : {value}", + "ai.step.file_rename": "Fichier renommé : {value}", + "ai.step.files": "Fichiers listés", + "ai.step.generic": "Outil utilisé : {tool}", + "ai.step.graph": "Graphe du vault consulté", + "ai.step.move": "Élément déplacé : {value}", + "ai.step.recent": "Fichiers récents consultés", + "ai.step.replace": "Remplacements : {value}", + "ai.step.search": "Recherche dans le vault : {value}", + "ai.step.search_paths": "Chemins recherchés : {value}", + "ai.step.tags": "Tags consultés", + "ai.step.tags_suggest": "Tags suggérés pour {value}", + "ai.step.vaults": "Liste des vaults consultée", + "ai.step.fetch_url": "Page web consultée : {value}", + "ai.step.web_search": "Recherche sur le web : {value}", "bookslm.copied": "Réponse copiée dans le presse-papiers", "bookslm.error": "Erreur du service AI", "bookslm.regenerate": "Régénérer", diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 9938ef1..f9bb1c7 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -335,7 +335,7 @@ async function main() { await test("agent tool calls collapse into a discreet steps block", () => { const b = new BooksLM(); const trace = b._renderToolActivity([ - { name: "read_file", ok: true }, + { name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } }, { name: "search", ok: true }, ]); assert.equal(trace.tagName, "DETAILS", "collapsible details element"); @@ -345,6 +345,18 @@ async function main() { assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside"); }); + await test("_stepText uses the backend label key, falls back to tool name", () => { + const b = new BooksLM(); + // No locales loaded in the JSDOM harness → t() returns the key, so the + // fallback path (legacy name) is what surfaces; assert it never throws + // and produces a non-empty string for both shapes. + const withStep = b._stepText({ name: "read_file", step: { key: "file_read", params: { value: "a.md" } } }); + const legacy = b._stepText({ name: "read_file" }); + assert.equal(typeof withStep, "string"); + assert.equal(typeof legacy, "string"); + assert.ok(legacy.length > 0); + }); + // ── 5. Source guards: no window.prompt() and authenticated BooksLM ── await test("ai.js no longer uses window.prompt()", async () => { const { readFileSync } = await import("node:fs"); diff --git a/tests/test_tool_labels.py b/tests/test_tool_labels.py new file mode 100644 index 0000000..975d4ca --- /dev/null +++ b/tests/test_tool_labels.py @@ -0,0 +1,67 @@ +"""Unit tests for the Notion-style step labels (backend.tools.labels).""" + +from backend.agent.loop import ToolCallRecord +from backend.tools.labels import GENERIC_KEY, _STEP_LABELS, tool_step_label +from backend.tools.registry import list_tools +from backend.tools.context import ToolScope + + +class TestToolStepLabel: + def test_every_in_app_tool_has_a_label(self): + # A registered tool without a mapping still degrades gracefully to the + # generic key, but the curated coverage must stay complete for the UI. + names = {spec.name for spec in list_tools(scope=ToolScope.IN_APP)} + assert names.issubset(set(_STEP_LABELS)), names - set(_STEP_LABELS) + + def test_label_argument_names_match_input_models(self): + # A mapped primary argument that the tool's input model does not + # actually declare would silently render "{value}" placeholders — + # verify every (name, arg) pair against the real schemas. + from backend.tools.registry import get_tool + + for name, (_key, arg) in _STEP_LABELS.items(): + if arg is None: + continue + spec = get_tool(name) + assert spec is not None, name + fields = spec.input_model.model_fields + assert arg in fields, f"{name}: input model has no field '{arg}'" + + def test_named_tool_returns_key_and_value(self): + label = tool_step_label("read_file", {"vault": "V", "path": "notes/a.md"}) + assert label["key"] == "file_read" + assert label["params"]["value"] == "notes/a.md" + + def test_search_tool_surfaces_the_query(self): + label = tool_step_label("search_fulltext", {"q": "pizza", "vault": "V"}) + assert label["key"] == "search" + assert label["params"]["value"] == "pizza" + + def test_no_argument_tools_have_empty_params(self): + label = tool_step_label("list_vaults", {}) + assert label["key"] == "vaults" + assert label["params"] == {} + + def test_missing_argument_degrades_to_key_only(self): + label = tool_step_label("read_file", {}) + assert label["key"] == "file_read" + assert "value" not in label["params"] + + def test_unknown_tool_falls_back_to_generic(self): + label = tool_step_label("future_tool_name", {"x": 1}) + assert label["key"] == GENERIC_KEY + assert label["params"]["tool"] == "future tool name" + + def test_arguments_none_is_accepted(self): + assert tool_step_label("list_tags", None)["key"] == "tags" + + def test_tool_call_record_carries_step(self): + rec = ToolCallRecord( + name="read_file", + arguments={"path": "a.md"}, + ok=True, + result={}, + step=tool_step_label("read_file", {"path": "a.md"}), + ) + assert rec.step["key"] == "file_read" + assert rec.step["params"]["value"] == "a.md" diff --git a/tests/test_web_tools.py b/tests/test_web_tools.py new file mode 100644 index 0000000..bef536a --- /dev/null +++ b/tests/test_web_tools.py @@ -0,0 +1,112 @@ +"""Unit tests for the web tools (backend.tools.web): web_search + fetch_url.""" + +from typing import Any + +import pytest + +import backend.tools.web as web +from backend.tools.api import get_tool_schemas +from backend.tools.context import ToolContext, ToolError, ToolMode, ToolRisk +from backend.tools.registry import get_tool + + +class FakeResponse: + def __init__(self, payload: Any = None, json_data: Any = None, status_code: int = 200, + content: bytes = b"", headers: dict | None = None, url: str = "https://example.com/x"): + self._payload = payload + self._json = json_data + self.status_code = status_code + self.content = content + self.headers = headers or {} + self.url = url + self.encoding = "utf-8" + + def json(self): + return self._json + + def raise_for_status(self): + if self.status_code >= 400: + raise web.httpx.HTTPStatusError("boom", request=None, response=self) # type: ignore[arg-type] + + +def _ctx() -> ToolContext: + return ToolContext(user={"username": "tester", "vaults": []}, mode=ToolMode.IN_APP) + + +class TestRegistration: + def test_tools_registered_read_only_in_app(self): + for name in ("web_search", "fetch_url"): + spec = get_tool(name) + assert spec is not None, name + assert spec.risk == ToolRisk.READ + assert name in [s["function"]["name"] for s in get_tool_schemas()] + + +class TestWebSearch: + def test_returns_trimmed_results(self, monkeypatch): + captured = {} + + def fake_get(url, params=None, **kw): + captured["url"] = url + captured["params"] = params + return FakeResponse(json_data={ + "results": [ + {"title": "A", "url": "https://a.dev", "content": "x" * 900, "score": 1.0, + "publishedDate": None}, + ] * 12 + }) + + monkeypatch.setattr(web.httpx, "get", fake_get) + out = web.web_search(_ctx(), web.WebSearchInput(query="pizza", max_results=3)) + assert out["engine"] == "searxng" + assert out["count"] == 3 + assert len(out["results"][0]["snippet"]) <= 600 + assert captured["params"]["q"] == "pizza" + + def test_empty_query_rejected(self): + with pytest.raises(ToolError): + web.web_search(_ctx(), web.WebSearchInput(query=" ")) + + def test_engine_unavailable_maps_to_tool_error(self, monkeypatch): + def boom(*a, **kw): + raise web.httpx.ConnectError("down") + + monkeypatch.setattr(web.httpx, "get", boom) + with pytest.raises(ToolError) as ei: + web.web_search(_ctx(), web.WebSearchInput(query="x")) + assert ei.value.code == "web_search_unavailable" + + +class TestFetchUrl: + def test_html_converted_to_text(self, monkeypatch): + html = (b"T&" + b"

hello

    " + b"
  • one
  • two
") + monkeypatch.setattr(web.httpx, "get", + lambda *a, **k: FakeResponse(content=html, + headers={"content-type": "text/html; charset=utf-8"})) + out = web.fetch_url(_ctx(), web.FetchUrlInput(url="https://example.com/x")) + assert out["title"] == "T&" + assert "hello" in out["text"] + assert "one" in out["text"] and "two" in out["text"] + assert "evil" not in out["text"] + assert out["status"] == 200 + + def test_private_address_rejected(self): + for url in ("http://127.0.0.1/admin", "http://169.254.1.1/x", "http://localhost:8000/api"): + with pytest.raises(ToolError) as ei: + web.fetch_url(_ctx(), web.FetchUrlInput(url=url)) + assert ei.value.code in ("ssrf_blocked", "dns_error", "invalid_scheme"), url + + def test_non_http_scheme_rejected(self): + with pytest.raises(ToolError) as ei: + web.fetch_url(_ctx(), web.FetchUrlInput(url="file:///etc/passwd")) + assert ei.value.code == "invalid_scheme" + + def test_binary_content_rejected(self, monkeypatch): + monkeypatch.setattr(web.httpx, "get", + lambda *a, **k: FakeResponse(content=b"%PDF-1.4...", + headers={"content-type": "application/pdf"})) + with pytest.raises(ToolError) as ei: + web.fetch_url(_ctx(), web.FetchUrlInput(url="https://example.com/f.pdf")) + assert ei.value.code == "unsupported_content_type"