From 256f5a4a03719dd0cd2f286feb021922f4112d2c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 15 Sep 2026 12:04:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20#91=20fen=C3=AAtre=20de=20r?= =?UTF-8?q?=C3=A9sultats=20=E2=80=94=20post=20=C3=A9pingl=C3=A9=20en=20hau?= =?UTF-8?q?t,=20fournisseur/mod=C3=A8le=20discret=20&=20bouton=20copier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 ++ docs/ROADMAP.md | 3 +- docs/features/ai-assistant-conversation-ux.md | 57 +++++++ frontend/js/bookslm.js | 145 +++++++++++++----- frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + frontend/style.css | 12 ++ frontend/sw.js | 2 +- tests/frontend/ai.test.mjs | 82 ++++++++++ 9 files changed, 275 insertions(+), 37 deletions(-) create mode 100644 docs/features/ai-assistant-conversation-ux.md diff --git a/CHANGELOG.md b/CHANGELOG.md index da2b077..a23e5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 alignée sur le même ordre markdown (groupes nav / édition / export / partage). 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é diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 76db8e5..3d005cd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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) | | 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) | +| 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é | |---|---|---| -| ✅ 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 | | ⚪ 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 | diff --git a/docs/features/ai-assistant-conversation-ux.md b/docs/features/ai-assistant-conversation-ux.md new file mode 100644 index 0000000..30860f3 --- /dev/null +++ b/docs/features/ai-assistant-conversation-ux.md @@ -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. \ No newline at end of file diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 8818e84..0049585 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -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; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 508abe6..74a2d1c 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 8e85bba..6102217 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/frontend/style.css b/frontend/style.css index fc837b2..c4babdd 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; } diff --git a/frontend/sw.js b/frontend/sw.js index 5b4ae55..629302f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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}`; diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index faf3063..d94d1b0 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -190,6 +190,88 @@ async function main() { 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 ── await test("ai.js no longer uses window.prompt()", async () => { const { readFileSync } = await import("node:fs");