feat(assistant): #91 fenêtre de résultats — post épinglé en haut, fournisseur/modèle discret & bouton copier
This commit is contained in:
+110
-35
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user