feat(assistant): #91 etapes enrichies (sous-sections Reflechon/Sources, chevrons, indicateur anime, suppression barre de chargement)
This commit is contained in:
@@ -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é
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]}}
|
||||
|
||||
+18
-1
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+153
-38
@@ -835,10 +835,6 @@ class BooksLM {
|
||||
<div class="bookslm-status"></div>
|
||||
<div class="bookslm-suggestions"></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-menu-layer">
|
||||
<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 {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 = '<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) {
|
||||
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;
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
+55
-21
@@ -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; }
|
||||
|
||||
+1
-1
@@ -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}`;
|
||||
|
||||
+95
-13
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user