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
+9
View File
@@ -43,6 +43,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
(pas de TOC, pas de PDF/Export). La fenêtre pop-out (`popout.html`) est (pas de TOC, pas de PDF/Export). La fenêtre pop-out (`popout.html`) est
alignée sur le même ordre markdown (groupes nav / édition / export / partage). alignée sur le même ordre markdown (groupes nav / édition / export / partage).
Tests : `tests/frontend/toolbar-order.test.mjs` (9). Tests : `tests/frontend/toolbar-order.test.mjs` (9).
- **#91 Assistant IA — fenêtre de résultats améliorée** : un message soumis et la
réponse de l'assistant **en cours d'écriture** sont épinglés en haut de la fenêtre
de messages (`.bookslm-msg.latest { order: -1 }`), pour toujours voir le résultat
que l'assistant rédige ; un libellé **discret** au-dessus de chaque réponse rapporte
le fournisseur et le modèle réellement utilisés (SSE `provider`/`model`) ; un bouton
« Copier » sous chaque bloc (utilisateur et assistant) copie le texte brut dans le
presse-papiers avec toast de confirmation. Fiche :
[docs/features/ai-assistant-conversation-ux.md](./docs/features/ai-assistant-conversation-ux.md)
Tests : `tests/frontend/ai.test.mjs` (71).
### Corrigé ### Corrigé
+2 -1
View File
@@ -171,6 +171,7 @@
| 84 | Consolidation & sécurité — revue statique 2026-09-13 (phase 1, BUG-021→034) | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 84 | Consolidation & sécurité — revue statique 2026-09-13 (phase 1, BUG-021→034) | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 83 | Barre d'outils d'édition mobile — ruban style Obsidian Android | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 83 | Barre d'outils d'édition mobile — ruban style Obsidian Android | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 88 | Assistant IA — contexte applicatif (documents ouverts, répertoire, recherche, fichiers récents) & liens de fichiers fiables (BUG-041, BUG-042) | 2.3.0 | [features/ai-app-context.md](./features/ai-app-context.md) | | 88 | Assistant IA — contexte applicatif (documents ouverts, répertoire, recherche, fichiers récents) & liens de fichiers fiables (BUG-041, BUG-042) | 2.3.0 | [features/ai-app-context.md](./features/ai-app-context.md) |
| 91 | Assistant IA — Fenêtre de résultats : post en haut, fournisseur/modèle discret & bouton copier | 2.3.0 | [features/ai-assistant-conversation-ux.md](./features/ai-assistant-conversation-ux.md) |
--- ---
@@ -178,7 +179,7 @@
| Priorité | Items | Effort total estimé | | Priorité | Items | Effort total estimé |
|---|---|---| |---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–90 | ~107 jours réalisés | | ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–91 | ~107 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours |
@@ -0,0 +1,57 @@
# #91 — Assistant IA — Fenêtre de résultats : post en haut, fournisseur/modèle & bouton copier
> **Statut :** ✅ Livré
> **Effort :** 0,5-1 jour | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [BooksLM #76](./bookslm.md) · [Assistant UX #80](./ai-assistant-ux.md) · [Changelog](../../CHANGELOG.md)
- **Description :** Trois améliorations d'expérience dans la fenêtre de résultats de
l'assistant IA (`frontend/js/bookslm.js`, `frontend/style.css`) :
1. **Post toujours en haut** : dès qu'un message est soumis, ce tour (le post
utilisateur **et** la réponse de l'assistant en cours d'écriture) est épinglé en
haut de la fenêtre de messages, afin que le résultat écrit par l'assistant reste
toujours visible — même après re-render successifs et auto-scroll.
2. **Fournisseur & modèle discrets** : juste au-dessus du bloc de texte rédigé par
l'assistant, un libellé discret rapporte le fournisseur et le modèle réellement
utilisés (fournis par le backend via le flux SSE `{token, provider, model}`).
3. **Bouton « Copier »** : sous le bloc soumis par l'utilisateur ainsi que sous le
bloc rédigé par l'assistant, un bouton copie le texte brut de ce bloc dans le
presse-papiers (avec toast de confirmation).
## A. Post toujours en haut — ✅ livré
- [x] **A1.** Chaque message est enveloppé dans un bloc `.bookslm-msg` ; le conteneur
`.bookslm-messages` est un flex column et les blocs du **dernier tour** (depuis le
dernier message utilisateur inclus) portent `.latest` → `order: -1`, les épinglant
en haut de la fenêtre.
- [x] **A2.** La détection du dernier tour se fait au rendu (`_renderMessages()`) en
recherchant le dernier message `role === 'user'` ; tout ce qui suit (sa réponse, ses
actions/cartes) reste groupé au-dessus de l'historique antérieur.
- [x] **A3.** L'auto-scroll vers le bas (`scrollTop = scrollHeight`) conserve le dernier
tour en haut, hors d'atteinte du contenu précédent.
## B. Fournisseur & modèle discrets — ✅ livré
- [x] **B1.** `_streamResponse()` capture `data.provider` et `data.model` du flux SSE
(déjà émis par `/chat` et `/agent`) sur le message assistant.
- [x] **B2.** Au rendu d'un message assistant, `.bookslm-msg-meta` affiche
« `fournisseur · modèle` » au-dessus du bloc (visible uniquement si l'info est reçue).
- [x] **B3.** Style discret (10px, `var(--text-secondary)`), non sélectionnable.
## C. Bouton « Copier » — ✅ livré
- [x] **C1.** `_appendCopyButton()` ajoute un bouton `.bookslm-copy-btn` sous chaque
bloc non vide (utilisateur et assistant) avec le texte i18n `bookslm.copy`.
- [x] **C2.** `_copyText()` copie le **texte brut** du message (`navigator.clipboard`,
repli `_fallbackCopy`) et affiche le toast `bookslm.copied`.
- [x] **C3.** Le bouton est masqué par défaut (opacité 0) et révélé au survol du bloc
`.bookslm-msg`.
## D. Tests & documentation — ✅ livré
- [x] **D1.** `tests/frontend/ai.test.mjs` : dernier tour épinglé, tag fournisseur/modèle
rendu, boutons copier présents sur les deux blocs, copie du texte brut, absence de
bouton pour un contenu vide.
- [x] **D2.** i18n FR/EN : `bookslm.copied`.
- [x] **D3.** CHANGELOG + Roadmap.
## E. Points d'attention
- Le tag provient du **backend réel** (`provider`/`model` du SSE) et reflète donc le
modèle effectif même lorsque l'utilisateur conserve la sélection « par défaut ».
- Le copier utilise le texte brut (pas le HTML rendu) — cohérent avec l'export de
conversation et évite d'embarquer des liens/balises de rendu.
+110 -35
View File
@@ -1783,48 +1783,119 @@ class BooksLM {
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
for (const msg of this._messages) { // The "latest turn" — the most recent user post and, when present, its
const bubble = document.createElement('div'); // assistant answer — is pinned to the top of the window. Everything from
bubble.className = `bookslm-bubble ${msg.role}`; // the last user message onward stays in view so the submitted post and
// the answer being written are always visible.
if (msg.role === 'assistant') { let lastUserIdx = -1;
const { text, actions } = this._extractActions(msg.content || ''); for (let i = this._messages.length - 1; i >= 0; i--) {
bubble.innerHTML = this._renderMarkdown(text); if (this._messages[i].role === 'user') { lastUserIdx = i; break; }
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;
}
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.appendChild(bubble);
} }
container.scrollTop = container.scrollHeight; 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) ───────────────────────────────────── // ── Actions (general assistant) ─────────────────────────────────────
_extractActions(text) { _extractActions(text) {
@@ -2253,6 +2324,10 @@ class BooksLM {
return; return;
} }
const chunk = data.token || data.content || ''; 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 (chunk) {
if (!assistantMsg.content) this._setActivity('working', t('ai.activity_streaming')); if (!assistantMsg.content) this._setActivity('working', t('ai.activity_streaming'));
assistantMsg.content += chunk; assistantMsg.content += chunk;
+1
View File
@@ -1776,6 +1776,7 @@
"bookslm.export": "Export conversation", "bookslm.export": "Export conversation",
"bookslm.toggle_sidebar": "Hide/Show AI sidebar", "bookslm.toggle_sidebar": "Hide/Show AI sidebar",
"bookslm.copy": "Copy", "bookslm.copy": "Copy",
"bookslm.copied": "Copied to clipboard",
"bookslm.error": "AI service error", "bookslm.error": "AI service error",
"bookslm.regenerate": "Regenerate", "bookslm.regenerate": "Regenerate",
"bookslm.suggestion_summary": "Summarize this directory", "bookslm.suggestion_summary": "Summarize this directory",
+1
View File
@@ -1776,6 +1776,7 @@
"bookslm.export": "Exporter la conversation", "bookslm.export": "Exporter la conversation",
"bookslm.toggle_sidebar": "Masquer/Afficher la sidebar AI", "bookslm.toggle_sidebar": "Masquer/Afficher la sidebar AI",
"bookslm.copy": "Copier", "bookslm.copy": "Copier",
"bookslm.copied": "Réponse copiée dans le presse-papiers",
"bookslm.error": "Erreur du service AI", "bookslm.error": "Erreur du service AI",
"bookslm.regenerate": "Régénérer", "bookslm.regenerate": "Régénérer",
"bookslm.suggestion_summary": "Résume ce répertoire", "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; } .bookslm-activity.error .bookslm-activity-spinner { animation: none; border-top-color: currentColor; }
@keyframes bookslm-spin { to { transform: rotate(360deg); } } @keyframes bookslm-spin { to { transform: rotate(360deg); } }
.bookslm-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; } .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 { 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.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; } .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 * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release. * 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 CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`;
+82
View File
@@ -190,6 +190,88 @@ async function main() {
panel.remove(); panel.remove();
}); });
// ── 4b. Latest turn pinned to the top + provider/model tag + copy buttons ──
await test("latest turn (user post + answer) is pinned to the top of messages", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "ancien" },
{ role: "assistant", content: "ancienne réponse" },
{ role: "user", content: "Nouveau sujet" },
{ role: "assistant", content: "Nouvelle réponse", provider: "deepseek", model: "deepseek-chat" },
];
b._renderMessages();
const msgs = panel.querySelectorAll(".bookslm-msg");
// Both bubbles of the latest turn carry the pinning class...
assert.ok(msgs[2].classList.contains("latest"), "latest user pinned");
assert.ok(msgs[3].classList.contains("latest"), "latest assistant pinned");
assert.ok(!msgs[0].classList.contains("latest"), "older user not pinned");
assert.ok(!msgs[1].classList.contains("latest"), "older assistant not pinned");
panel.remove();
});
await test("assistant answer shows a discreet provider/model tag", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "Bonjour" },
{ role: "assistant", content: "Réponse", provider: "deepseek", model: "deepseek-v3" },
];
b._renderMessages();
const meta = panel.querySelector(".bookslm-msg.assistant.latest .bookslm-msg-meta");
assert.ok(meta, "provider/model meta line rendered");
assert.equal(meta.textContent, "deepseek · deepseek-v3");
panel.remove();
});
await test("both user and assistant blocks expose a copy button", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "Question copiable" },
{ role: "assistant", content: "Réponse copiable", provider: "deepseek", model: "m" },
];
b._renderMessages();
const wrapped = panel.querySelectorAll(".bookslm-msg");
assert.equal(wrapped.length, 2);
// Both wraps carry a copy button.
assert.ok(panel.querySelector(".bookslm-msg.user .bookslm-copy-btn"), "user copy button");
assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-copy-btn"), "assistant copy button");
panel.remove();
});
await test("copy button copies the raw message text to the clipboard", async () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [{ role: "user", content: "texte à copier" }];
b._renderMessages();
const copied = [];
b._copyText = (t) => copied.push(t);
const btn = panel.querySelector(".bookslm-msg.user .bookslm-copy-btn");
assert.ok(btn, "copy button present");
btn.click();
assert.deepEqual(copied, ["texte à copier"], "copy handler receives raw content");
panel.remove();
});
await test("empty user content renders no copy button", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
b._messages = [{ role: "user", content: " " }];
b._renderMessages();
assert.equal(panel.querySelectorAll(".bookslm-copy-btn").length, 0);
panel.remove();
});
// ── 5. Source guards: no window.prompt() and authenticated BooksLM ── // ── 5. Source guards: no window.prompt() and authenticated BooksLM ──
await test("ai.js no longer uses window.prompt()", async () => { await test("ai.js no longer uses window.prompt()", async () => {
const { readFileSync } = await import("node:fs"); const { readFileSync } = await import("node:fs");