feat(assistant): #91 fenêtre de résultats — post épinglé en haut, fournisseur/modèle discret & bouton copier
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 57s
CI / test (push) Successful in 2m41s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 10m40s

This commit is contained in:
2026-09-15 12:04:38 -04:00
parent b69cb9b0f8
commit 256f5a4a03
9 changed files with 275 additions and 37 deletions
+110 -35
View File
@@ -1783,48 +1783,119 @@ class BooksLM {
if (!container) return;
container.innerHTML = '';
for (const msg of this._messages) {
const bubble = document.createElement('div');
bubble.className = `bookslm-bubble ${msg.role}`;
if (msg.role === 'assistant') {
const { text, actions } = this._extractActions(msg.content || '');
bubble.innerHTML = this._renderMarkdown(text);
if (msg.toolCalls && msg.toolCalls.length) {
bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild);
}
if (actions.length) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'bookslm-actions';
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
bubble.appendChild(actionsDiv);
}
if (msg.confirmation) {
bubble.appendChild(this._renderConfirmationCard(msg));
}
if (msg.sources && msg.sources.length) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'bookslm-sources';
for (const src of msg.sources) {
const badge = document.createElement('span');
badge.className = 'bookslm-source-badge';
badge.textContent = `📄 ${src.split('/').pop()}`;
badge.title = src;
badge.addEventListener('click', () => this.openFile(src));
sourcesDiv.appendChild(badge);
}
bubble.appendChild(sourcesDiv);
}
} else {
bubble.textContent = msg.content;
}
// The "latest turn" — the most recent user post and, when present, its
// assistant answer — is pinned to the top of the window. Everything from
// the last user message onward stays in view so the submitted post and
// the answer being written are always visible.
let lastUserIdx = -1;
for (let i = this._messages.length - 1; i >= 0; i--) {
if (this._messages[i].role === 'user') { lastUserIdx = i; break; }
}
for (let i = 0; i < this._messages.length; i++) {
const msg = this._messages[i];
const bubble = this._chatBubble(msg, i >= lastUserIdx && lastUserIdx >= 0);
container.appendChild(bubble);
}
container.scrollTop = container.scrollHeight;
}
/**
* Build a single chat bubble (shared by user and assistant messages).
*
* Each bubble is wrapped in a `bookslm-msg` block. Bubbles flagged as part
* of the latest turn carry `.latest` and are pinned to the very top of the
* messages window (`.bookslm-msg.latest { order: -1; }`), so a submitted
* post and the answer being streamed stay visible regardless of auto-scroll
* to the bottom and of prior conversation history.
*/
_chatBubble(msg, isLatest) {
const wrap = document.createElement('div');
wrap.className = 'bookslm-msg ' + msg.role + (isLatest ? ' latest' : '');
if (msg.role === 'assistant') {
// Discreet provider/model tag above the answer. Uses the actual engine
// reported by the backend SSE stream, so it reflects the real model even
// on the provider-default selection.
if (msg.provider || msg.model) {
const metaLine = document.createElement('div');
metaLine.className = 'bookslm-msg-meta';
const label = [msg.provider, msg.model].filter(Boolean).join(' · ');
metaLine.textContent = label;
metaLine.title = label;
wrap.appendChild(metaLine);
}
const bubble = document.createElement('div');
bubble.className = 'bookslm-bubble assistant';
const { text, actions } = this._extractActions(msg.content || '');
bubble.innerHTML = this._renderMarkdown(text);
if (msg.toolCalls && msg.toolCalls.length) {
bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild);
}
if (actions.length) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'bookslm-actions';
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
bubble.appendChild(actionsDiv);
}
if (msg.confirmation) {
bubble.appendChild(this._renderConfirmationCard(msg));
}
if (msg.sources && msg.sources.length) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'bookslm-sources';
for (const src of msg.sources) {
const badge = document.createElement('span');
badge.className = 'bookslm-source-badge';
badge.textContent = `📄 ${src.split('/').pop()}`;
badge.title = src;
badge.addEventListener('click', () => this.openFile(src));
sourcesDiv.appendChild(badge);
}
bubble.appendChild(sourcesDiv);
}
wrap.appendChild(bubble);
} else {
const bubble = document.createElement('div');
bubble.className = 'bookslm-bubble user';
bubble.textContent = msg.content;
wrap.appendChild(bubble);
}
// Copy button under the message block.
this._appendCopyButton(wrap, msg);
return wrap;
}
/** Copy the raw message text to the clipboard (user or assistant block). */
_appendCopyButton(wrap, msg) {
const content = String(msg.content || '').trim();
if (!content) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bookslm-copy-btn';
btn.textContent = t('bookslm.copy');
btn.title = t('bookslm.copy');
btn.setAttribute('aria-label', t('bookslm.copy'));
btn.addEventListener('click', () => {
this._copyText(content);
});
wrap.appendChild(btn);
}
_copyText(text) {
const done = () => showToast(t('bookslm.copied'), 'success');
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => this._fallbackCopy(text, done));
return;
}
} catch { /* fall through */ }
this._fallbackCopy(text, done);
}
// ── Actions (general assistant) ─────────────────────────────────────
_extractActions(text) {
@@ -2253,6 +2324,10 @@ class BooksLM {
return;
}
const chunk = data.token || data.content || '';
// Remember which provider/model actually answered, so a discreet tag
// can be shown above the assistant's text block.
if (data.provider) assistantMsg.provider = data.provider;
if (data.model) assistantMsg.model = data.model;
if (chunk) {
if (!assistantMsg.content) this._setActivity('working', t('ai.activity_streaming'));
assistantMsg.content += chunk;