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;
+1
View File
@@ -1776,6 +1776,7 @@
"bookslm.export": "Export conversation",
"bookslm.toggle_sidebar": "Hide/Show AI sidebar",
"bookslm.copy": "Copy",
"bookslm.copied": "Copied to clipboard",
"bookslm.error": "AI service error",
"bookslm.regenerate": "Regenerate",
"bookslm.suggestion_summary": "Summarize this directory",
+1
View File
@@ -1776,6 +1776,7 @@
"bookslm.export": "Exporter la conversation",
"bookslm.toggle_sidebar": "Masquer/Afficher la sidebar AI",
"bookslm.copy": "Copier",
"bookslm.copied": "Réponse copiée dans le presse-papiers",
"bookslm.error": "Erreur du service AI",
"bookslm.regenerate": "Régénérer",
"bookslm.suggestion_summary": "Résume ce répertoire",
+12
View File
@@ -9435,6 +9435,18 @@ body.popup-mode .content-area {
.bookslm-activity.error .bookslm-activity-spinner { animation: none; border-top-color: currentColor; }
@keyframes bookslm-spin { to { transform: rotate(360deg); } }
.bookslm-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
/* The newest turn (the assistant's answer being written) is pinned to the
very top of the messages window via flex `order`, so a submitted post and
its live result stay visible regardless of auto-scroll and prior history. */
.bookslm-msg { display: flex; flex-direction: column; gap: 4px; }
.bookslm-msg.latest { order: -1; }
.bookslm-msg-meta { font-size: 10px; color: var(--text-secondary); opacity: 0.85;
padding: 0 2px; align-self: flex-start; user-select: none; }
.bookslm-copy-btn { align-self: flex-end; font-size: 10px; padding: 2px 8px;
border-radius: 6px; border: 1px solid var(--border); background: var(--surface);
color: var(--text-secondary); cursor: pointer; opacity: 0; transition: opacity 0.15s; }
.bookslm-msg:hover .bookslm-copy-btn { opacity: 1; }
.bookslm-copy-btn:hover { color: var(--accent); border-color: var(--accent); }
.bookslm-bubble { max-width: 85%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; word-wrap: break-word; }
.bookslm-bubble.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
.bookslm-bubble.assistant { align-self: flex-start; background: var(--surface2); color: var(--text-primary); border-bottom-left-radius: 4px; }
+1 -1
View File
@@ -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 = 'v6';
const SW_VERSION = 'v8';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;