feat(assistant): #91 etapes enrichies (sous-sections Reflechon/Sources, chevrons, indicateur anime, suppression barre de chargement)
CI / lint (push) Successful in 1m23s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 11m8s

This commit is contained in:
2026-09-15 22:53:15 -04:00
parent 431fb5626d
commit f630771150
13 changed files with 477 additions and 80 deletions
+15
View File
@@ -62,6 +62,21 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Fiche : Fiche :
[docs/features/ai-assistant-conversation-ux.md](./docs/features/ai-assistant-conversation-ux.md) [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). 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é ### Corrigé
+32 -1
View File
@@ -333,10 +333,41 @@ def _effective_model(provider: str | None, requested: str | None) -> str:
return PROVIDERS.get(provider, {}).get("model", "") or "" 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: def _tool_event_sse(rec) -> str:
"""Serialize one executed tool call as an SSE ``tool`` event.""" """Serialize one executed tool call as an SSE ``tool`` event."""
payload = json.dumps( 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, ensure_ascii=False,
) )
return f"event: tool\ndata: {payload}\n\n" return f"event: tool\ndata: {payload}\n\n"
+6 -2
View File
@@ -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]: 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()) cleaned = " ".join((text or "").split())
return {"key": "thought", "params": {"value": cleaned[:400]}} return {"key": "thought", "params": {"value": cleaned[:1200]}}
+18 -1
View File
@@ -150,12 +150,29 @@ def web_search(ctx, params: WebSearchInput) -> dict[str, Any]:
"score": item.get("score"), "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, "query": query,
"engine": "searxng", "engine": "searxng",
"results": results, "results": results,
"count": len(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( @tool(
+21 -3
View File
@@ -107,14 +107,32 @@
- [x] **G2.** Événements en **direct** : les steps sont poussés sur une file - [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 `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). « 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 - [x] **G3.** Sous-section **« Réflexion ▶ / ▼ »** : quand le modèle émet un texte
texte intermédiaire avec ses appels d'outils, il est publié comme event SSE intermédiaire avec ses appels d'outils, il est publié comme event SSE `step` et
`step` et affiché dans la liste (`. thought` Notion). 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`, - [x] **G4.** Nouveaux outils principaux côté **web** (`backend/tools/web.py`,
risques READ, scope IN_APP, SSRF-guard + limite de taille) : risques READ, scope IN_APP, SSRF-guard + limite de taille) :
`web_search` (SearXNG auto-hébergé, configurable via `OBSIGATE_SEARXNG_URL`) `web_search` (SearXNG auto-hébergé, configurable via `OBSIGATE_SEARXNG_URL`)
et `fetch_url` (lecture d'une page publique, HTML → texte). et `fetch_url` (lecture d'une page publique, HTML → texte).
Steps affichés : « Recherche sur le web : … » / « Page web consultée : … ». 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) ### Outils restants — documentés pour le futur (hors #91)
La catégorie Notion « étapes » peut s'étendre ; chaque futur outil devra être un La catégorie Notion « étapes » peut s'étendre ; chaque futur outil devra être un
+153 -38
View File
@@ -835,10 +835,6 @@ class BooksLM {
<div class="bookslm-status"></div> <div class="bookslm-status"></div>
<div class="bookslm-suggestions"></div> <div class="bookslm-suggestions"></div>
<div class="bookslm-messages"></div> <div class="bookslm-messages"></div>
<div class="bookslm-activity hidden" role="status" aria-live="polite">
<span class="bookslm-activity-spinner" aria-hidden="true"></span>
<span class="bookslm-activity-text"></span>
</div>
<div class="bookslm-attachments"></div> <div class="bookslm-attachments"></div>
<div class="bookslm-menu-layer"> <div class="bookslm-menu-layer">
<div class="bookslm-command-menu hidden" role="listbox" aria-label="${t('ai.commands')}"></div> <div class="bookslm-command-menu hidden" role="listbox" aria-label="${t('ai.commands')}"></div>
@@ -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 {'idle'|'working'|'done'|'error'} kind
* @param {string} [text] * @param {string} [text]
*/ */
_setActivity(kind, text) { _setActivity(kind, text) {
if (!this._panel) return; this._activityKind = kind;
if (this._activityHideTimer) { this._activityText = text || '';
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');
} }
_updateStatus(data) { _updateStatus(data) {
@@ -1904,15 +1894,24 @@ class BooksLM {
metaLine.title = label; metaLine.title = label;
wrap.appendChild(metaLine); 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. // answer instead of a visible list.
const running = this._isRunningMessage(msg);
if (msg.toolCalls && msg.toolCalls.length) { 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'); const bubble = document.createElement('div');
bubble.className = 'bookslm-bubble assistant'; bubble.className = 'bookslm-bubble 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);
// 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) { if (actions.length) {
const actionsDiv = document.createElement('div'); const actionsDiv = document.createElement('div');
actionsDiv.className = 'bookslm-actions'; actionsDiv.className = 'bookslm-actions';
@@ -2111,24 +2110,140 @@ class BooksLM {
return t('ai.tool_call', { name: call.name }); return t('ai.tool_call', { name: call.name });
} }
/** Agent tool calls collapse into a discreet “N steps” line (Notion-style). */ /** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */
_renderToolActivity(toolCalls) { _chevron() {
const wrap = document.createElement('details'); const c = document.createElement('span');
wrap.className = 'bookslm-tool-trace'; c.className = 'bookslm-chevron';
const summary = document.createElement('summary'); c.setAttribute('aria-hidden', 'true');
const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count'; return c;
summary.textContent = t(countKey, { count: toolCalls.length }); }
wrap.appendChild(summary);
for (const call of toolCalls) { /** True while this very message is the one being produced. */
const line = document.createElement('div'); _isRunningMessage(msg) {
line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : ''); return !!this._isLoading && this._messages[this._messages.length - 1] === msg;
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); * Agent tool calls collapse into a discreet “N étapes” block (Notion-style).
} *
return wrap; * 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 = '<i></i><i></i><i></i>';
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) { _renderConfirmationCard(msg) {
const conf = msg.confirmation; const conf = msg.confirmation;
@@ -2391,7 +2506,6 @@ class BooksLM {
.map((f) => f.path || f); .map((f) => f.path || f);
} }
this._setActivity('done', t('ai.activity_done')); this._setActivity('done', t('ai.activity_done'));
this._activityHideTimer = setTimeout(() => this._setActivity('idle'), 1500);
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') { if (e.name !== 'AbortError') {
assistantMsg.content = `⚠ Error: ${e.message}`; assistantMsg.content = `⚠ Error: ${e.message}`;
@@ -2459,6 +2573,7 @@ class BooksLM {
ok: data.ok !== false, ok: data.ok !== false,
arguments: data.arguments || {}, arguments: data.arguments || {},
step: data.step || null, step: data.step || null,
sources: Array.isArray(data.sources) ? data.sources : [],
}); });
this._setActivity('working', t('ai.activity_tool', { name: data.name })); this._setActivity('working', t('ai.activity_tool', { name: data.name }));
return; return;
+3
View File
@@ -1784,6 +1784,9 @@
"ai.steps_count_plural": "{count} steps", "ai.steps_count_plural": "{count} steps",
"ai.activity_thinking": "Thinking…", "ai.activity_thinking": "Thinking…",
"ai.step.thought": "Thought: {value}", "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.backlinks": "Analyzed backlinks",
"ai.step.backup_diff": "Compared backup: {value}", "ai.step.backup_diff": "Compared backup: {value}",
"ai.step.backup_restore": "Restored backup: {value}", "ai.step.backup_restore": "Restored backup: {value}",
+3
View File
@@ -1784,6 +1784,9 @@
"ai.steps_count_plural": "{count} étapes", "ai.steps_count_plural": "{count} étapes",
"ai.activity_thinking": "Réflexion…", "ai.activity_thinking": "Réflexion…",
"ai.step.thought": "Réflexion : {value}", "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.backlinks": "Backlinks analysés",
"ai.step.backup_diff": "Comparaison de sauvegarde : {value}", "ai.step.backup_diff": "Comparaison de sauvegarde : {value}",
"ai.step.backup_restore": "Sauvegarde restaurée : {value}", "ai.step.backup_restore": "Sauvegarde restaurée : {value}",
+55 -21
View File
@@ -9421,19 +9421,19 @@ body.popup-mode .content-area {
.bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px; .bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px;
background: var(--surface2); overflow: hidden; } background: var(--surface2); overflow: hidden; }
.bookslm-status .bookslm-context-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; } .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). */ /* Assistant activity — no loading bar above the composer anymore: in agent
.bookslm-activity { display: flex; align-items: center; gap: 8px; padding: 6px 16px; mode the animated dots sit in the steps header (« ●●● 3 étapes ▶ »), in
font-size: 12px; color: var(--text-secondary); border-top: 1px solid var(--border); plain chat they sit in the pending answer. Pure CSS, reduced-motion aware. */
background: var(--bg-secondary); } .bookslm-dots { display: inline-flex; align-items: center; gap: 2px; }
.bookslm-activity.hidden { display: none; } .bookslm-dots i { width: 4px; height: 4px; border-radius: 50%; background: currentColor;
.bookslm-activity.error { color: #f87171; } opacity: 0.25; animation: bookslm-dot 1.2s ease-in-out infinite; }
.bookslm-activity.done { color: var(--success, #3fb950); } .bookslm-dots i:nth-child(2) { animation-delay: 0.15s; }
.bookslm-activity-spinner { width: 12px; height: 12px; border: 2px solid var(--border); .bookslm-dots i:nth-child(3) { animation-delay: 0.3s; }
border-top-color: var(--accent); border-radius: 50%; flex-shrink: 0; @keyframes bookslm-dot {
animation: bookslm-spin 0.8s linear infinite; } 0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
.bookslm-activity.done .bookslm-activity-spinner, 30% { opacity: 1; transform: translateY(-2px); }
.bookslm-activity.error .bookslm-activity-spinner { animation: none; border-top-color: currentColor; } }
@keyframes bookslm-spin { to { transform: rotate(360deg); } } .bookslm-pending { display: flex; align-items: center; height: 20px; color: var(--text-secondary); }
/* Notion-style AI thread: only this zone scrolls; entries stack vertically /* 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 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, 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); } .bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
/* Agent-mode toggle in the header. */ /* Agent-mode toggle in the header. */
.bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); } .bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); }
/* Tool-call trace shown above an assistant answer (agent mode). */ /* Tool-call trace shown above an assistant answer (agent mode).
/* Agent tool calls collapse into a discreet “N steps” line (Notion-style). */ 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 { margin: 0 0 8px; }
.bookslm-tool-trace summary { font-size: 11px; color: var(--text-secondary); .bookslm-tool-trace > summary { display: inline-flex; align-items: center; gap: 6px;
cursor: pointer; user-select: none; list-style: none; } font-size: 11px; color: var(--text-secondary); cursor: pointer; user-select: none;
.bookslm-tool-trace summary::before { content: '> '; } list-style: none; padding: 2px 0; }
.bookslm-tool-trace[open] summary { margin-bottom: 4px; } .bookslm-tool-trace > summary::-webkit-details-marker { display: none; }
.bookslm-tool-trace .bookslm-tool-line { padding-left: 12px; } .bookslm-tool-trace > summary:hover { color: var(--text-primary); }
.bookslm-tool-line { font-size: 11px; color: var(--text-secondary); } .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; } .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). */ /* Mutation confirmation card (two-step propose/apply). */
.bookslm-confirm { flex-wrap: wrap; } .bookslm-confirm { flex-wrap: wrap; }
.bookslm-confirm-diff { flex-basis: 100%; width: 100%; margin-top: 4px; } .bookslm-confirm-diff { flex-basis: 100%; width: 100%; margin-top: 4px; }
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release. * 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 CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`;
+95 -13
View File
@@ -406,17 +406,86 @@ async function main() {
await test("agent tool calls collapse into a discreet steps block", () => { await test("agent tool calls collapse into a discreet steps block", () => {
const b = new BooksLM(); const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity([ const trace = b._renderToolActivity([
{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } }, { name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } },
{ name: "search", ok: true }, { name: "search", ok: true },
]); ], msg, false);
assert.equal(trace.tagName, "DETAILS", "collapsible details element"); assert.equal(trace.tagName, "DETAILS", "collapsible details element");
const summary = trace.querySelector("summary"); const summary = trace.querySelector("summary");
assert.ok(summary, "summary present"); 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"); 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", () => { await test("_stepText uses the backend label key, falls back to tool name", () => {
const b = new BooksLM(); const b = new BooksLM();
// No locales loaded in the JSDOM harness → t() returns the key, so the // 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"); assert.ok(del.includes("refreshAIPickers()"), "deleting a provider key refreshes the picker");
}); });
// ── 42b. Assistant activity indicator (#82) ── // ── 42b. Assistant activity indicator (#91 — plus de barre de chargement) ──
await test("activity indicator reflects the request workflow", () => { await test("no loading bar above the composer; activity is recorded as state", () => {
const b = new BooksLM(); const b = new BooksLM();
b._panel = b._render(); b._panel = b._render();
document.body.appendChild(b._panel); document.body.appendChild(b._panel);
const el = b._panel.querySelector(".bookslm-activity"); assert.equal(b._panel.querySelector(".bookslm-activity"), null,
assert.ok(el, "activity element present"); "the composer loading bar is gone");
assert.ok(el.classList.contains("hidden"), "hidden initially"); // _setActivity stays the single entry point of the streaming code paths.
b._setActivity("working", "Envoi…"); b._setActivity("working", "Envoi…");
assert.ok(!el.classList.contains("hidden"), "shown while working"); assert.equal(b._activityKind, "working");
assert.ok(el.classList.contains("working")); assert.equal(b._activityText, "Envoi…");
assert.equal(el.querySelector(".bookslm-activity-text").textContent, "Envoi…");
b._setActivity("error", "Échec");
assert.ok(el.classList.contains("error"));
b._setActivity("idle"); 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(); b._panel.remove();
}); });
+51
View File
@@ -1024,3 +1024,54 @@ class TestEffectiveModel:
assert _effective_model("nope-not-a-provider", None) == "" assert _effective_model("nope-not-a-provider", None) == ""
assert _effective_model(None, 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"] == []
+24
View File
@@ -67,6 +67,30 @@ class TestWebSearch:
with pytest.raises(ToolError): with pytest.raises(ToolError):
web.web_search(_ctx(), web.WebSearchInput(query=" ")) 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 test_engine_unavailable_maps_to_tool_error(self, monkeypatch):
def boom(*a, **kw): def boom(*a, **kw):
raise web.httpx.ConnectError("down") raise web.httpx.ConnectError("down")