diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c068f6..b0cfbc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,21 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Fiche : [docs/features/ai-assistant-conversation-ux.md](./docs/features/ai-assistant-conversation-ux.md) Tests : `tests/frontend/ai.test.mjs` (76), `tests/test_tool_labels.py` (8), `tests/test_web_tools.py` (8). +- **#91 Assistant IA — section d'étapes enrichie** : l'en-tête affiche « N étapes » + suivi d'un **chevron ▶ / ▼** (fin du préfixe « > ») et, pendant l'exécution, un + **indicateur animé** de trois points devant le libellé ; la **barre de chargement** + au-dessus de la zone de saisie est supprimée (en chat simple, l'indicateur s'affiche + dans la bulle de réponse en attente). Chaque note de raisonnement devient une + **sous-section « Réflexion ▶ / ▼ »** contenant le texte du modèle, et les recherches + web ajoutent une sous-section **« Sources (N) »** avec les **liens cliquables** + (le backend émet `sources [{title, url}]` dans l'événement SSE `tool`, résultats + `web_search` plafonnés à 8 + page `fetch_url`). Les états d'ouverture (étapes, + réflexion, sources) sont mémorisés sur le message : un token re-rendu ne referme + jamais ce que l'utilisateur a déplié. `web_search` signale désormais explicitement + au modèle une instance SearXNG sans résultat (moteurs suspendus/CAPTCHA) au lieu de + le laisser relancer la même recherche jusqu'au quota. + Tests : `tests/frontend/ai.test.mjs` (82), `tests/test_bookslm.py::TestToolEventSources` (4), + `tests/test_web_tools.py` (10). ### Corrigé diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index 89cfe9d..77e826f 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -333,10 +333,41 @@ def _effective_model(provider: str | None, requested: str | None) -> str: return PROVIDERS.get(provider, {}).get("model", "") or "" +def _tool_sources(rec) -> list[dict[str, str]]: + """Compact web sources of a tool result (rendered as links in the UI). + + Only the web tools produce sources: ``web_search`` returns ranked results, + ``fetch_url`` a single page. Everything else yields an empty list so the + SSE payload stays small. + """ + data = rec.result if isinstance(rec.result, dict) else {} + name = rec.name or "" + sources: list[dict[str, str]] = [] + if name == "web_search": + for item in (data.get("results") or [])[:8]: + if not isinstance(item, dict): + continue + url = item.get("url") or "" + if not url: + continue + sources.append({"title": item.get("title") or url, "url": url}) + elif name == "fetch_url": + url = data.get("url") or "" + if url: + sources.append({"title": data.get("title") or url, "url": url}) + return sources + + 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}, + { + "name": rec.name, + "ok": rec.ok, + "arguments": rec.arguments, + "step": rec.step, + "sources": _tool_sources(rec), + }, ensure_ascii=False, ) return f"event: tool\ndata: {payload}\n\n" diff --git a/backend/tools/labels.py b/backend/tools/labels.py index 4507080..15325d4 100644 --- a/backend/tools/labels.py +++ b/backend/tools/labels.py @@ -84,6 +84,10 @@ def tool_step_label(name: str, arguments: dict[str, Any] | None = None) -> dict[ def thought_step_label(text: str) -> dict[str, Any]: - """Step descriptor for one intermediate reasoning note of the model.""" + """Step descriptor for one intermediate reasoning note of the model. + + The note is shown expanded under a “Thought” sub-section in the UI, so it + is kept long enough to be readable (truncation is the safety net). + """ cleaned = " ".join((text or "").split()) - return {"key": "thought", "params": {"value": cleaned[:400]}} + return {"key": "thought", "params": {"value": cleaned[:1200]}} diff --git a/backend/tools/web.py b/backend/tools/web.py index aa48344..0b07b4b 100644 --- a/backend/tools/web.py +++ b/backend/tools/web.py @@ -150,12 +150,29 @@ def web_search(ctx, params: WebSearchInput) -> dict[str, Any]: "score": item.get("score"), } ) - return { + unresponsive = [ + name for entry in (data.get("unresponsive_engines") or []) + for name in ([entry[0]] if isinstance(entry, (list, tuple)) and entry else [entry]) + if isinstance(name, str) + ] + payload: dict[str, Any] = { "query": query, "engine": "searxng", "results": results, "count": len(results), } + if unresponsive: + payload["unresponsive_engines"] = unresponsive[:8] + if not results: + # An instance whose upstream engines are all blocked (CAPTCHA / rate + # limit) answers 200 with an empty list. Without an explicit hint the + # model retries the same search until it burns its tool quota. + payload["warning"] = ( + "Aucun résultat : les moteurs de recherche de l'instance SearXNG sont " + f"indisponibles ({', '.join(unresponsive[:5]) or 'inconnus'}). " + "Ne relance pas la même recherche — dis-le à l'utilisateur." + ) + return payload @tool( diff --git a/docs/features/ai-assistant-conversation-ux.md b/docs/features/ai-assistant-conversation-ux.md index 02b546c..498d160 100644 --- a/docs/features/ai-assistant-conversation-ux.md +++ b/docs/features/ai-assistant-conversation-ux.md @@ -107,14 +107,32 @@ - [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] **G3.** Sous-section **« Réflexion ▶ / ▼ »** : quand le modèle émet un texte + intermédiaire avec ses appels d'outils, il est publié comme event SSE `step` et + rendu comme une **sous-section repliable** à l'intérieur du bloc d'étapes — + le chevron passe de ▶ à ▼ à l'ouverture et le texte (jusqu'à 1 200 caractères) + s'affiche en dessous, en retrait sur un filet vertical. - [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 : … ». + - [x] **G5.** **Sources web** : le backend enrichit l'événement SSE `tool` de + `sources [{title, url}]` (`_tool_sources` : résultats `web_search` plafonnés à 8, + page `fetch_url`) et le frontend affiche une sous-section « Sources (N) » **ouverte + par défaut** avec les liens cliquables (`target="_blank"`, `rel="noopener + noreferrer"`). Aucun résultat → pas de section. + - [x] **G6.** **En-tête d'étapes** : libellé « N étapes » suivi du chevron + ▶ / ▼ (fin du préfixe « > »), et **indicateur animé** (trois points en + pulsation décalée, pur CSS, respecte `prefers-reduced-motion`) devant le libellé + pendant l'exécution. La **barre de chargement** au-dessus de la zone de saisie est + supprimée ; en chat simple, l'indicateur s'affiche dans la bulle de réponse en + attente. Les états d'ouverture (étapes, réflexion, sources) sont mémorisés sur le + message : le re-rendu d'un token ne referme jamais ce que l'utilisateur a ouvert. + - [x] **G7.** `web_search` avertit explicitement le modèle quand l'instance SearXNG + ne remonte aucun résultat (moteurs amont suspendus/CAPTCHA) : `warning` + + `unresponsive_engines` dans le résultat — sans ce signal, l'assistant relançait la + même recherche jusqu'au quota d'outils. ### Outils restants — documentés pour le futur (hors #91) La catégorie Notion « étapes » peut s'étendre ; chaque futur outil devra être un diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 5945c92..1ae74a2 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -835,10 +835,6 @@ class BooksLM {
-