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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user