From f630771150fd5c652f52923fd206d977a1af8c0f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 15 Sep 2026 22:53:15 -0400 Subject: [PATCH] feat(assistant): #91 etapes enrichies (sous-sections Reflechon/Sources, chevrons, indicateur anime, suppression barre de chargement) --- CHANGELOG.md | 15 ++ backend/bookslm_routes.py | 33 ++- backend/tools/labels.py | 8 +- backend/tools/web.py | 19 +- docs/features/ai-assistant-conversation-ux.md | 24 ++- frontend/js/bookslm.js | 191 ++++++++++++++---- frontend/locales/en.json | 3 + frontend/locales/fr.json | 3 + frontend/style.css | 76 +++++-- frontend/sw.js | 2 +- tests/frontend/ai.test.mjs | 108 ++++++++-- tests/test_bookslm.py | 51 +++++ tests/test_web_tools.py | 24 +++ 13 files changed, 477 insertions(+), 80 deletions(-) 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 {
-
@@ -1698,24 +1694,18 @@ class BooksLM { } /** - * Show the assistant activity state in the panel (visual workflow). + * Record the assistant activity state. + * + * The dedicated loading bar above the composer was removed: in agent mode + * the animated indicator now lives in the steps header, and in plain chat + * the pending answer shows an inline indicator. Kept as the single entry + * point for the streaming code paths (state is informational only). * @param {'idle'|'working'|'done'|'error'} kind * @param {string} [text] */ _setActivity(kind, text) { - if (!this._panel) return; - if (this._activityHideTimer) { - clearTimeout(this._activityHideTimer); - this._activityHideTimer = null; - } - const el = this._panel.querySelector('.bookslm-activity'); - if (!el) return; - const label = el.querySelector('.bookslm-activity-text'); - if (label) label.textContent = text || ''; - el.classList.toggle('hidden', kind === 'idle'); - el.classList.toggle('working', kind === 'working'); - el.classList.toggle('done', kind === 'done'); - el.classList.toggle('error', kind === 'error'); + this._activityKind = kind; + this._activityText = text || ''; } _updateStatus(data) { @@ -1904,15 +1894,24 @@ class BooksLM { metaLine.title = label; wrap.appendChild(metaLine); } - // Notion-style: the tool calls collapse into a “N steps” line above the + // Notion-style: the tool calls collapse into a “N étapes” line above the // answer instead of a visible list. + const running = this._isRunningMessage(msg); if (msg.toolCalls && msg.toolCalls.length) { - wrap.appendChild(this._renderToolActivity(msg.toolCalls)); + wrap.appendChild(this._renderToolActivity(msg.toolCalls, msg, running)); } const bubble = document.createElement('div'); bubble.className = 'bookslm-bubble assistant'; const { text, actions } = this._extractActions(msg.content || ''); bubble.innerHTML = this._renderMarkdown(text); + // Plain chat has no steps block: the pending answer itself carries the + // animated indicator until the first token arrives. + if (running && !text && !actions.length) { + const pending = document.createElement('div'); + pending.className = 'bookslm-pending'; + pending.appendChild(this._dots()); + bubble.appendChild(pending); + } if (actions.length) { const actionsDiv = document.createElement('div'); actionsDiv.className = 'bookslm-actions'; @@ -2111,24 +2110,140 @@ class BooksLM { 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; + /** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */ + _chevron() { + const c = document.createElement('span'); + c.className = 'bookslm-chevron'; + c.setAttribute('aria-hidden', 'true'); + return c; + } + + /** True while this very message is the one being produced. */ + _isRunningMessage(msg) { + return !!this._isLoading && this._messages[this._messages.length - 1] === msg; + } + + /** + * Agent tool calls collapse into a discreet “N étapes” block (Notion-style). + * + * The header carries an animated indicator while the agent works; expanding + * it reveals the chronological steps, where each reasoning note becomes a + * « Réflexion » sub-section and web searches add a clickable sources list. + * Open states live on the message so the re-render of every streamed token + * never collapses what the user expanded. + */ + _renderToolActivity(toolCalls, msg, running) { + const wrap = document.createElement('details'); + wrap.className = 'bookslm-tool-trace'; + wrap.open = !!(msg && msg._stepsOpen); + + const summary = document.createElement('summary'); + if (running) { + const dots = this._dots(); + dots.classList.add('bookslm-steps-dots'); + summary.appendChild(dots); } + const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count'; + const label = document.createElement('span'); + label.className = 'bookslm-steps-label'; + label.textContent = t(countKey, { count: toolCalls.length }); + summary.appendChild(label); + summary.appendChild(this._chevron()); + if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`); + wrap.appendChild(summary); + + const body = document.createElement('div'); + body.className = 'bookslm-steps-body'; + const sources = []; + for (const call of toolCalls) { + // A reasoning note is its own collapsible sub-section, not a line. + if (call.step && call.step.key === 'thought') { + body.appendChild(this._renderThought(call, msg)); + continue; + } + 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; + body.appendChild(line); + if (Array.isArray(call.sources)) { + for (const s of call.sources) { + if (s && s.url) sources.push(s); + } + } + } + if (sources.length) body.appendChild(this._renderSources(sources, msg)); + wrap.appendChild(body); + + wrap.addEventListener('toggle', () => { if (msg) msg._stepsOpen = wrap.open; }); + return wrap; + } + + /** Animated three-dot activity indicator (pure CSS, reduced-motion aware). */ + _dots() { + const dots = document.createElement('span'); + dots.className = 'bookslm-dots'; + dots.setAttribute('aria-hidden', 'true'); + dots.innerHTML = ''; + return dots; + } + + /** One collapsible « Réflexion » block holding the model's reasoning note. */ + _renderThought(call, msg) { + const text = (call.step && call.step.params && call.step.params.value) || ''; + const box = document.createElement('details'); + box.className = 'bookslm-thought'; + box.open = !!(msg && msg._thoughtOpen && msg._thoughtOpen[text]); + const summary = document.createElement('summary'); + const label = document.createElement('span'); + label.className = 'bookslm-thought-label'; + label.textContent = t('ai.thought_label'); + summary.appendChild(label); + summary.appendChild(this._chevron()); + box.appendChild(summary); + const body = document.createElement('div'); + body.className = 'bookslm-thought-text'; + body.textContent = text; + box.appendChild(body); + box.addEventListener('toggle', () => { + if (!msg) return; + msg._thoughtOpen = msg._thoughtOpen || {}; + msg._thoughtOpen[text] = box.open; + }); + return box; + } + + /** Web sources gathered from the run's searches, as real links. */ + _renderSources(list, msg) { + const box = document.createElement('details'); + box.className = 'bookslm-web-sources'; + // Visible by default: the point of the section is to expose the links. + box.open = !(msg && msg._sourcesOpen === false); + const summary = document.createElement('summary'); + const label = document.createElement('span'); + label.className = 'bookslm-web-sources-label'; + label.textContent = t('ai.sources_label', { count: list.length }); + summary.appendChild(label); + summary.appendChild(this._chevron()); + box.appendChild(summary); + const ul = document.createElement('ul'); + ul.className = 'bookslm-web-sources-list'; + for (const s of list) { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = s.url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = s.title || s.url; + a.title = s.url; + li.appendChild(a); + ul.appendChild(li); + } + box.appendChild(ul); + box.addEventListener('toggle', () => { if (msg) msg._sourcesOpen = box.open; }); + return box; + } _renderConfirmationCard(msg) { const conf = msg.confirmation; @@ -2391,7 +2506,6 @@ class BooksLM { .map((f) => f.path || f); } this._setActivity('done', t('ai.activity_done')); - this._activityHideTimer = setTimeout(() => this._setActivity('idle'), 1500); } catch (e) { if (e.name !== 'AbortError') { assistantMsg.content = `⚠ Error: ${e.message}`; @@ -2459,6 +2573,7 @@ class BooksLM { ok: data.ok !== false, arguments: data.arguments || {}, step: data.step || null, + sources: Array.isArray(data.sources) ? data.sources : [], }); this._setActivity('working', t('ai.activity_tool', { name: data.name })); return; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index d049353..76082e4 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1784,6 +1784,9 @@ "ai.steps_count_plural": "{count} steps", "ai.activity_thinking": "Thinking…", "ai.step.thought": "Thought: {value}", + "ai.thought_label": "Thought", + "ai.sources_label": "Sources ({count})", + "ai.steps_running": "running", "ai.step.backlinks": "Analyzed backlinks", "ai.step.backup_diff": "Compared backup: {value}", "ai.step.backup_restore": "Restored backup: {value}", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 9e84935..b16eca0 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1784,6 +1784,9 @@ "ai.steps_count_plural": "{count} étapes", "ai.activity_thinking": "Réflexion…", "ai.step.thought": "Réflexion : {value}", + "ai.thought_label": "Réflexion", + "ai.sources_label": "Sources ({count})", + "ai.steps_running": "exécution en cours", "ai.step.backlinks": "Backlinks analysés", "ai.step.backup_diff": "Comparaison de sauvegarde : {value}", "ai.step.backup_restore": "Sauvegarde restaurée : {value}", diff --git a/frontend/style.css b/frontend/style.css index 1f2ec6f..87b0297 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9421,19 +9421,19 @@ body.popup-mode .content-area { .bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px; background: var(--surface2); overflow: hidden; } .bookslm-status .bookslm-context-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; } -/* Assistant activity indicator (visual workflow: sending/streaming/tools/done/error). */ -.bookslm-activity { display: flex; align-items: center; gap: 8px; padding: 6px 16px; - font-size: 12px; color: var(--text-secondary); border-top: 1px solid var(--border); - background: var(--bg-secondary); } -.bookslm-activity.hidden { display: none; } -.bookslm-activity.error { color: #f87171; } -.bookslm-activity.done { color: var(--success, #3fb950); } -.bookslm-activity-spinner { width: 12px; height: 12px; border: 2px solid var(--border); - border-top-color: var(--accent); border-radius: 50%; flex-shrink: 0; - animation: bookslm-spin 0.8s linear infinite; } -.bookslm-activity.done .bookslm-activity-spinner, -.bookslm-activity.error .bookslm-activity-spinner { animation: none; border-top-color: currentColor; } -@keyframes bookslm-spin { to { transform: rotate(360deg); } } +/* Assistant activity — no loading bar above the composer anymore: in agent + mode the animated dots sit in the steps header (« ●●● 3 étapes ▶ »), in + plain chat they sit in the pending answer. Pure CSS, reduced-motion aware. */ +.bookslm-dots { display: inline-flex; align-items: center; gap: 2px; } +.bookslm-dots i { width: 4px; height: 4px; border-radius: 50%; background: currentColor; + opacity: 0.25; animation: bookslm-dot 1.2s ease-in-out infinite; } +.bookslm-dots i:nth-child(2) { animation-delay: 0.15s; } +.bookslm-dots i:nth-child(3) { animation-delay: 0.3s; } +@keyframes bookslm-dot { + 0%, 60%, 100% { opacity: 0.25; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-2px); } +} +.bookslm-pending { display: flex; align-items: center; height: 20px; color: var(--text-secondary); } /* Notion-style AI thread: only this zone scrolls; entries stack vertically with generous spacing. The latest user post is anchored to the top of the visible area on send: the post is anchored flush at the top (block:start, @@ -9497,16 +9497,50 @@ body.popup-mode .content-area { .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). */ -/* Agent tool calls collapse into a discreet “N steps” line (Notion-style). */ +/* Tool-call trace shown above an assistant answer (agent mode). + The header is the live indicator (« ●●● 3 étapes ▶ »); expanding it reveals + the chronological steps, thinking notes as « Réflexion » sub-sections and + web sources as clickable links. */ .bookslm-tool-trace { margin: 0 0 8px; } -.bookslm-tool-trace summary { font-size: 11px; color: var(--text-secondary); - cursor: pointer; user-select: none; list-style: none; } -.bookslm-tool-trace summary::before { content: '> '; } -.bookslm-tool-trace[open] summary { margin-bottom: 4px; } -.bookslm-tool-trace .bookslm-tool-line { padding-left: 12px; } -.bookslm-tool-line { font-size: 11px; color: var(--text-secondary); } +.bookslm-tool-trace > summary { display: inline-flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--text-secondary); cursor: pointer; user-select: none; + list-style: none; padding: 2px 0; } +.bookslm-tool-trace > summary::-webkit-details-marker { display: none; } +.bookslm-tool-trace > summary:hover { color: var(--text-primary); } +.bookslm-tool-trace[open] > summary { margin-bottom: 4px; } +.bookslm-steps-dots { color: var(--accent); } +.bookslm-steps-body { display: flex; flex-direction: column; gap: 3px; } +.bookslm-chevron { font-size: 8px; line-height: 1; opacity: 0.7; } +.bookslm-chevron::before { content: '▶'; } +details[open] > summary .bookslm-chevron::before { content: '▼'; } +.bookslm-thought { margin-left: 12px; } +.bookslm-thought > summary { display: inline-flex; align-items: center; gap: 5px; + font-size: 11px; color: var(--text-secondary); cursor: pointer; user-select: none; + list-style: none; } +.bookslm-thought > summary::-webkit-details-marker { display: none; } +.bookslm-thought > summary:hover { color: var(--text-primary); } +.bookslm-thought-text { margin: 4px 0 6px 6px; padding-left: 9px; + border-left: 2px solid var(--border); font-size: 11.5px; line-height: 1.5; + color: var(--text-secondary); font-style: italic; white-space: pre-wrap; + word-break: break-word; } +.bookslm-web-sources { margin-left: 12px; } +.bookslm-web-sources > summary { display: inline-flex; align-items: center; gap: 5px; + font-size: 11px; color: var(--text-secondary); cursor: pointer; user-select: none; + list-style: none; } +.bookslm-web-sources > summary::-webkit-details-marker { display: none; } +.bookslm-web-sources > summary:hover { color: var(--text-primary); } +.bookslm-web-sources-list { list-style: none; margin: 4px 0 6px 6px; padding: 0; + display: flex; flex-direction: column; gap: 3px; border-left: 2px solid var(--border); + padding-left: 9px; } +.bookslm-web-sources-list a { font-size: 11px; color: var(--accent); + text-decoration: none; word-break: break-word; } +.bookslm-web-sources-list a::before { content: '↗ '; opacity: 0.7; } +.bookslm-web-sources-list a:hover { text-decoration: underline; } +.bookslm-tool-line { font-size: 11px; color: var(--text-secondary); padding-left: 12px; } .bookslm-tool-line.failed { color: #f87171; } +@media (prefers-reduced-motion: reduce) { + .bookslm-dots i { animation: none; opacity: 0.6; } +} /* Mutation confirmation card (two-step propose/apply). */ .bookslm-confirm { flex-wrap: wrap; } .bookslm-confirm-diff { flex-basis: 100%; width: 100%; margin-top: 4px; } diff --git a/frontend/sw.js b/frontend/sw.js index 439f0ae..674aab8 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -11,7 +11,7 @@ * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * a separate store. Bumping SW_VERSION invalidates it on every release. */ -const SW_VERSION = 'v18'; +const SW_VERSION = 'v19'; const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`; diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index c27540c..a8c8745 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -406,17 +406,86 @@ async function main() { await test("agent tool calls collapse into a discreet steps block", () => { const b = new BooksLM(); + const msg = {}; const trace = b._renderToolActivity([ { name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } }, { name: "search", ok: true }, - ]); + ], msg, false); assert.equal(trace.tagName, "DETAILS", "collapsible details element"); const summary = trace.querySelector("summary"); assert.ok(summary, "summary present"); - assert.ok(summary.textContent.trim().length > 0, "summary shows the steps label"); + const label = summary.querySelector(".bookslm-steps-label"); + assert.ok(label && label.textContent.length > 0, "summary shows the steps label"); + assert.ok(summary.querySelector(".bookslm-chevron"), "chevron after the label"); + assert.equal(summary.textContent.includes(">"), false, "no legacy '> ' prefix"); + assert.equal(summary.querySelector(".bookslm-steps-dots"), null, "no indicator when idle"); assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside"); }); + await test("the steps header carries the animated indicator while running", () => { + const b = new BooksLM(); + const msg = {}; + const trace = b._renderToolActivity( + [{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } }], + msg, true); + const summary = trace.querySelector("summary"); + const dots = summary.querySelector(".bookslm-steps-dots"); + assert.ok(dots, "dots shown before the label while the agent works"); + assert.equal(dots.querySelectorAll("i").length, 3, "three animated dots"); + assert.equal(dots.getAttribute("aria-hidden"), "true"); + assert.ok(summary.getAttribute("aria-label"), "the state is announced to screen readers"); + }); + + await test("reasoning notes become « Réflexion » sub-sections, open state kept", () => { + const b = new BooksLM(); + const msg = {}; + const calls = [ + { name: "", ok: true, step: { key: "thought", params: { value: "Je dois d'abord lister le vault." } } }, + { name: "list_vaults", ok: true, step: { key: "vaults", params: {} } }, + ]; + const trace = b._renderToolActivity(calls, msg, false); + const thought = trace.querySelector("details.bookslm-thought"); + assert.ok(thought, "the note is its own collapsible sub-section"); + assert.ok(thought.querySelector(".bookslm-thought-label"), "labelled (Réflexion / Thought)"); + assert.equal(thought.querySelector(".bookslm-thought-text").textContent, + "Je dois d'abord lister le vault.", "the thought text sits inside"); + assert.equal(thought.open, false, "collapsed by default → ▶"); + assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 1, "the note is not a plain line"); + // Expanding is remembered so the per-token re-render cannot collapse it. + thought.open = true; + thought.dispatchEvent(new Event("toggle")); + assert.equal(msg._thoughtOpen["Je dois d'abord lister le vault."], true); + const again = b._renderToolActivity(calls, msg, false); + assert.equal(again.querySelector("details.bookslm-thought").open, true, + "the open state survives a re-render"); + }); + + await test("web searches add a sources section with clickable links", () => { + const b = new BooksLM(); + const msg = {}; + const trace = b._renderToolActivity([ + { + name: "web_search", ok: true, step: { key: "web_search", params: { value: "météo Paris" } }, + sources: [ + { title: "Météo Paris", url: "https://exemple.fr/meteo" }, + { title: "", url: "https://exemple.fr/b" }, + ], + }, + ], msg, false); + const box = trace.querySelector("details.bookslm-web-sources"); + assert.ok(box, "sources section present"); + assert.equal(box.open, true, "visible by default so the links show up"); + const links = box.querySelectorAll(".bookslm-web-sources-list a"); + assert.equal(links.length, 2, "one link per source"); + assert.equal(links[0].getAttribute("href"), "https://exemple.fr/meteo"); + assert.equal(links[0].target, "_blank"); + assert.equal(links[0].rel, "noopener noreferrer"); + assert.equal(links[1].textContent, "https://exemple.fr/b", "falls back to the URL label"); + // No web search → no sources section. + const bare = b._renderToolActivity([{ name: "read_file", ok: true }], {}, false); + assert.equal(bare.querySelector(".bookslm-web-sources"), null); + }); + 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 @@ -1518,22 +1587,35 @@ async function main() { assert.ok(del.includes("refreshAIPickers()"), "deleting a provider key refreshes the picker"); }); - // ── 42b. Assistant activity indicator (#82) ── - await test("activity indicator reflects the request workflow", () => { + // ── 42b. Assistant activity indicator (#91 — plus de barre de chargement) ── + await test("no loading bar above the composer; activity is recorded as state", () => { const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); - const el = b._panel.querySelector(".bookslm-activity"); - assert.ok(el, "activity element present"); - assert.ok(el.classList.contains("hidden"), "hidden initially"); + assert.equal(b._panel.querySelector(".bookslm-activity"), null, + "the composer loading bar is gone"); + // _setActivity stays the single entry point of the streaming code paths. b._setActivity("working", "Envoi…"); - assert.ok(!el.classList.contains("hidden"), "shown while working"); - assert.ok(el.classList.contains("working")); - assert.equal(el.querySelector(".bookslm-activity-text").textContent, "Envoi…"); - b._setActivity("error", "Échec"); - assert.ok(el.classList.contains("error")); + assert.equal(b._activityKind, "working"); + assert.equal(b._activityText, "Envoi…"); b._setActivity("idle"); - assert.ok(el.classList.contains("hidden"), "hidden when idle"); + assert.equal(b._activityKind, "idle"); + b._panel.remove(); + }); + + await test("plain chat shows the pending indicator inside the empty answer", () => { + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + b._messages = [{ role: "assistant", content: "", toolCalls: [], sources: [] }]; + b._isLoading = true; + b._renderMessages(); + const pending = b._panel.querySelector(".bookslm-bubble.assistant .bookslm-pending .bookslm-dots"); + assert.ok(pending, "animated dots shown while the first token is pending"); + assert.equal(pending.querySelectorAll("i").length, 3, "three dots"); + b._isLoading = false; + b._renderMessages(); + assert.equal(b._panel.querySelector(".bookslm-pending"), null, "indicator gone once settled"); b._panel.remove(); }); diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py index d58c03c..c3822b4 100644 --- a/tests/test_bookslm.py +++ b/tests/test_bookslm.py @@ -1024,3 +1024,54 @@ class TestEffectiveModel: assert _effective_model("nope-not-a-provider", None) == "" assert _effective_model(None, None) == "" + + +class TestToolEventSources: + """Web sources surfaced in the « sources » section of the steps block.""" + + @staticmethod + def _record(name, result): + from backend.agent.loop import ToolCallRecord + + return ToolCallRecord(name=name, arguments={}, ok=True, result=result) + + def test_web_search_results_become_sources(self): + from backend.bookslm_routes import _tool_sources + + rec = self._record("web_search", {"results": [ + {"title": "Météo Paris", "url": "https://exemple.fr/meteo"}, + {"title": "Sans URL", "url": ""}, + {"title": "", "url": "https://exemple.fr/b"}, + ]}) + sources = _tool_sources(rec) + assert sources == [ + {"title": "Météo Paris", "url": "https://exemple.fr/meteo"}, + {"title": "https://exemple.fr/b", "url": "https://exemple.fr/b"}, + ] + + def test_fetch_url_yields_one_source(self): + from backend.bookslm_routes import _tool_sources + + rec = self._record("fetch_url", {"url": "https://exemple.fr/a", "title": "Titre"}) + assert _tool_sources(rec) == [{"title": "Titre", "url": "https://exemple.fr/a"}] + + def test_other_tools_yield_no_sources(self): + from backend.bookslm_routes import _tool_sources + + assert _tool_sources(self._record("read_file", {"content": "x"})) == [] + assert _tool_sources(self._record("web_search", {"error": "boom"})) == [] + assert _tool_sources(self._record("fetch_url", "not-a-dict")) == [] + + def test_sse_payload_carries_the_sources(self): + import json as _json + + from backend.bookslm_routes import _tool_event_sse + + rec = self._record("web_search", {"results": [{"title": "T", "url": "https://exemple.fr/t"}]}) + payload = _tool_event_sse(rec) + assert payload.startswith("event: tool\ndata: ") + data = _json.loads(payload.split("data: ", 1)[1]) + assert data["name"] == "web_search" + assert data["sources"] == [{"title": "T", "url": "https://exemple.fr/t"}] + # Non-web tools keep the key, empty, so the UI never guesses. + assert _json.loads(_tool_event_sse(self._record("read_file", {})).split("data: ", 1)[1])["sources"] == [] diff --git a/tests/test_web_tools.py b/tests/test_web_tools.py index bef536a..81d5ad5 100644 --- a/tests/test_web_tools.py +++ b/tests/test_web_tools.py @@ -67,6 +67,30 @@ class TestWebSearch: with pytest.raises(ToolError): web.web_search(_ctx(), web.WebSearchInput(query=" ")) + def test_empty_result_set_warns_about_blocked_engines(self, monkeypatch): + """An all-blocked instance answers 200 with no results: the model must + be told instead of retrying the same search until the quota burns.""" + monkeypatch.setattr(web.httpx, "get", lambda *a, **kw: FakeResponse(json_data={ + "results": [], + "number_of_results": 0, + "unresponsive_engines": [["duckduckgo", "CAPTCHA"], ["google", "access denied"]], + })) + out = web.web_search(_ctx(), web.WebSearchInput(query="meteo montreal")) + assert out["count"] == 0 + assert out["unresponsive_engines"] == ["duckduckgo", "google"] + assert "warning" in out + assert "indisponibles" in out["warning"] + + def test_results_carry_no_warning(self, monkeypatch): + monkeypatch.setattr(web.httpx, "get", lambda *a, **kw: FakeResponse(json_data={ + "results": [{"title": "A", "url": "https://a.dev", "content": "x"}], + "unresponsive_engines": [["brave", "rate limited"]], + })) + out = web.web_search(_ctx(), web.WebSearchInput(query="pizza")) + assert out["count"] == 1 + assert "warning" not in out + assert out["unresponsive_engines"] == ["brave"] + def test_engine_unavailable_maps_to_tool_error(self, monkeypatch): def boom(*a, **kw): raise web.httpx.ConnectError("down")