feat(assistant): #91 etapes enrichies (sous-sections Reflechon/Sources, chevrons, indicateur anime, suppression barre de chargement)
This commit is contained in:
+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;
|
||||
|
||||
Reference in New Issue
Block a user