diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff6f8e..e750251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,16 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Corrigé +- **#91 — Bulle utilisateur trop étroite & ancre d'envoi retardée** : le plafond de + largeur était appliqué **deux fois** (wrapper 80 % × bulle 85 % ≈ 68 % du fil), d'où + des lignes très courtes ; le plafond est désormais porté par la seule bulle (90 %), + le wrapper s'étirant → ~81 % de la largeur du fil (≈48 caractères/ligne au lieu de + ~28). Par ailleurs, le rendu du message assistant « placeholder » écrasait la + position de défilement juste après l'envoi (l'ancre était annulée : la question + restait en bas jusqu'au premier token). `_isLoading` est maintenant posé avant ce + rendu et l'ancrage d'envoi est **instantané** — la question passe en haut du fil dès + la soumission et y reste pendant tout le streaming. + - **BUG-041 — Assistant IA bloqué sur un répertoire vide** : l'assistant ne renvoie plus `⚠ Error: Aucun fichier markdown trouvé dans ce dossier` (HTTP 404). Le contexte vide dégrade vers le prompt Général augmenté d'un bloc « Dossier diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b19e45b..622db91 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -78,6 +78,22 @@ - [x] Personnalisation (clé à molette) : ajouter / supprimer / réordonner les commandes - [x] i18n FR/EN + tests frontend (helpers purs) + E2E mobile +### 92. Assistant IA — Écosystème d'outils (phase 2 : web étendu, sources connectées, documents) + +- **Effort :** 3-5 jours | **Impact :** 🟠 | **Zone :** backend (`backend/tools/`) +- **Dépend de :** #91 (registre + section « steps » + `web_search`/`fetch_url` livrés) +- **Description :** étendre le catalogue d'outils de l'assistant au-delà du vault, en + suivant la feuille de route technique détaillée : + [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) (frameworks évalués, + bibliothèques par catégorie, transverse retry/cache/secrets/async). +- **Sous-tâches :** + - [ ] `web_search` : chaîne de repli sans clé (DuckDuckGo) + fournisseurs optionnels (Tavily, Brave, SerpAPI, Exa) + - [ ] `fetch_url` : pages dynamiques via Playwright (worker isolé) ; crawl multi-pages Scrapy en tâche de fond + - [ ] Sources connectées : Gitea/GitHub (priorité haute) puis Google Drive / OneDrive (OAuth2 `authlib`) + - [ ] Production de documents : conversion, tableurs, PDF/Word (outils WRITE + confirmation) + - [ ] Transverse : `tenacity` (backoff), cache SQLite des résultats web avec TTL, secrets via Infisical + - [ ] Chaque outil : libellé `labels.py` + clés i18n `ai.step.*` FR/EN + tests (httpx mocké) + --- ## ⚪ Backlog — Sécurité, architecture & performance (P0/P1) @@ -182,8 +198,9 @@ | ✅ 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 | +| ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours | -| **Total restant** | **6 items + finitions** | **~28-42 jours** | +| **Total restant** | **7 items + finitions** | **~31-47 jours** | --- diff --git a/docs/features/ai-assistant-conversation-ux.md b/docs/features/ai-assistant-conversation-ux.md index 35514f5..f5ecd9c 100644 --- a/docs/features/ai-assistant-conversation-ux.md +++ b/docs/features/ai-assistant-conversation-ux.md @@ -25,7 +25,8 @@ - [x] **A1.** `_renderMessages({ anchor: true })` appelé à l'envoi du message, à l'ouverture d'un contexte, au rechargement d'une session et à la reprise après confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView` - fluide `block: 'start'`, repli calcul `scrollTop += delta` si indisponible. + `block: 'start'` (instantané à l'envoi, fluide à l'ouverture d'une session), + repli calcul `scrollTop += delta` si indisponible. - [x] **A2.** Re-rendus de streaming : tant que la réponse arrive (`_isLoading`) et que l'utilisateur n'a pas touché à la molette (flag `_pinnedTurn`, libéré sur `wheel`/`touchmove`), chaque frame re-ancre instantanément la question en haut — diff --git a/docs/features/ai-tools-roadmap.md b/docs/features/ai-tools-roadmap.md new file mode 100644 index 0000000..092a9d2 --- /dev/null +++ b/docs/features/ai-tools-roadmap.md @@ -0,0 +1,102 @@ +# #92 — Assistant IA — Écosystème d'outils : feuille de route technique + +> **Statut :** ⚪ Backlog (phase 1 livrée dans #91) +> **Effort estimé :** 3-5 jours pour la phase 2 | **Impact :** 🟠 +> **Références :** [Roadmap](../ROADMAP.md) · [Outils & MCP #79](./ai-tools-mcp.md) · +> [Fenêtre de discussion #91](./ai-assistant-conversation-ux.md) · [Changelog](../../CHANGELOG.md) + +## 1. Ce qui existe déjà (phase 1, #91) + +| Élément | État | +|---|---| +| Registre d'outils typé (`@tool`, Pydantic, risque, scope, rate-limit, audit) | ✅ `backend/tools/registry.py` | +| Boucle agent multi-tours + confirmation en deux étapes | ✅ `backend/agent/loop.py` | +| Section « N étapes » Notion-style avec libellés humains + événements SSE en direct | ✅ `backend/tools/labels.py` | +| Recherche web auto-hébergée (`web_search` via SearXNG) | ✅ `backend/tools/web.py` | +| Lecture d'une page publique (`fetch_url`, garde SSRF, HTML→texte) | ✅ `backend/tools/web.py` | +| Outils vault (lecture, recherche, navigation, mutations confirmées) | ✅ `backend/tools/service.py` (26 outils) | + +## 2. Frameworks d'agents — décision + +ObsiGate **ne migre pas** vers un framework externe : la boucle maison +(`run_agent`) est déjà testée, typée, auditable et intégrée au flux SSE +(critique pour la section « steps » en direct). Les candidats restent suivis : + +| Framework | Point fort | Verdict pour ObsiGate | +|---|---|---| +| **Pydantic AI** | validation type-safe, cœur léger | inspirant : la validation existe déjà via Pydantic dans `registry.call_tool` | +| **LangChain / LangGraph** | écosystème, workflows complexes | dépendance lourde ; à reconsidérer seulement si multi-agents | +| **LlamaIndex** | RAG clé en main | la recherche locale (TF-IDF + embeddings, #70) couvre déjà le besoin | +| **SmolAgents** | agents qui écrivent du code | risque d'exécution : hors périmètre | +| **LightAgent** | ultra-léger, mémoire | redondant avec la mémoire de session déjà fournie | + +**Règle :** tout nouvel outil = `@tool` dans `backend/tools/` + un libellé dans +`labels.py` + deux clés i18n (`ai.step.` FR/EN) + un test. Aucune +réécriture de la boucle n'est nécessaire. + +## 3. Phase 2 — catégories à implémenter + +### 3.1 Recherche web étendue (`web_search`) +- **Fallback sans clé** : aujourd'hui SearXNG auto-hébergé (`OBSIGATE_SEARXNG_URL`). + Prévoir une chaîne de repli si l'instance est indisponible (DuckDuckGo HTML). +- **Fournisseurs optionnels** (clé dans Infisical, jamais en dur) : Tavily + (résultats orientés agents), Brave Search API, SerpAPI (Google), Exa. + Interface unifiée type `anysearch` pour un sélecteur de fournisseur unique. +- **Paramètres déjà exposés** : `category` (general/news/it/science), `language`, + `page`, `max_results`. + +### 3.2 Lecture de pages (`fetch_url`) +- Pages **statiques** : couvert (httpx + extraction texte maison). +- Pages **dynamiques (SPA/React)** : `playwright` (async) en option, exécuté dans + un worker isolé (jamais dans le process web) — images Docker lourdes à prévoir. +- **Crawl multi-pages** : `scrapy` uniquement en tâche de fond, jamais déclenché + par le modèle sans confirmation (WRITE/`confirm`). +- Alternative légère de parsing : `lxml` ou `beautifulsoup4` si l'extraction + maison devient insuffisante (aujourd'hui volontairement sans dépendance). + +### 3.3 Sources connectées (« Searched connected sources ») +Chaque intégration reste **derrière le registre** (risque, scope, rate-limit, +audit) et **jamais** avec un token en dur : +- **Gitea / GitHub** : `httpx` direct (déjà utilisé) ou `gitea-sdk` / `PyGithub`. + Priorité haute : ObsiGate est hébergé sur Gitea (issues, PR, commits). +- **Google Drive / Gmail / Calendar** : `google-api-python-client` (+ `PyDrive4` + pour les tâches simples). OAuth2 via `authlib`. +- **OneDrive / SharePoint** : `onedrive-personal-sdk` (async, Microsoft Graph). +- **Notion / Slack / Jira / Confluence / Salesforce** : SDK officiels ou + `httpx` + OAuth2 générique. +- **Voie recommandée** : privilégier le **serveur MCP externe** (#79) pour les + services tiers — l'attaque surface reste hors du cœur d'ObsiGate. + +### 3.4 Fichiers et production de documents +- Conversion de formats, tableurs (`openpyxl`), documents (`python-docx`, `pypdf`), + graphiques (`matplotlib`) : outils **WRITE** (confirmation obligatoire), + exécutés hors requête web si lourd (tâche de fond + SSE). +- Pièces jointes : déjà couvert par l'upload vault + `python-multipart`. + +### 3.5 Raisonnement interne (« thought », « planned the task ») +- Livré : la note intermédiaire du modèle devient une étape visible. +- Extension possible : exposer les itérations de la boucle (`iterations`) comme + étapes de planification quand un outil de plan est ajouté. + +## 4. Transverse — à faire avec la phase 2 + +| Sujet | Bibliothèque / approche | Où | +|---|---|---| +| Réessais avec backoff | `tenacity` (ou boucle maison) | appels réseau des outils | +| Cache des résultats web | table SQLite dédiée + TTL (`OBSIGATE_WEB_CACHE_TTL`) | `backend/tools/web.py` | +| Rate limiting | déjà en place (`backend/tools/ratelimit.py`) | registre | +| Secrets | Infisical / variables d'environnement | jamais en dur | +| Async | `httpx` (async) pour ne pas bloquer la boucle | nouveaux outils réseau | +| Fallback | chaque outil externe doit **échouer proprement** (`ToolError` + message) | registre | +| Observabilité | audit déjà en place (`backend/tools/audit.py`) | registre | + +## 5. Points d'attention + +- **Sécurité d'abord** : tout outil réseau passe par la garde SSRF (`_assert_public_http_url`), + des limites de taille, et n'est jamais exposé sans rate-limit. +- **Coût** : chaque outil supplémentaire augmente le prompt système (schémas). + Mesurer (`/api/ai/status`) et exposer les outils par lots si besoin. +- **Testabilité** : les outils réseau se testent avec `httpx` mocké (voir + `tests/test_web_tools.py`), jamais contre Internet en CI. +- **Une seule source d'affichage** : ne pas dupliquer les étapes côté frontend — + le libellé vient du backend (`labels.py`), la traduction du locale. \ No newline at end of file diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index a2efed6..561bc16 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -1803,7 +1803,7 @@ class BooksLM { container.appendChild(this._chatBubble(msg)); } - if (opts.anchor) this._anchorLatestUser(); + if (opts.anchor) this._anchorLatestUser({ instant: !!opts.instant }); else if (this._isLoading && this._pinnedTurn) this._anchorLatestUser({ instant: true }); else container.scrollTop = prevTop; } @@ -2279,7 +2279,9 @@ class BooksLM { .map((m) => ({ role: m.role, content: m.content })); this._messages.push({ role: 'user', content: text }); - this._renderMessages({ anchor: true }); + // Instant anchor: the post must already sit at the top when the answer + // starts streaming (a smooth animation would leave it at the bottom). + this._renderMessages({ anchor: true, instant: true }); let provider = null; let model = null; @@ -2309,8 +2311,12 @@ class BooksLM { const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload }; this._messages.push(assistantMsg); - this._renderMessages(); + // Loading must be flagged *before* this render: otherwise it would take + // the "restore previous scroll" branch and cancel the anchor applied to + // the user post, leaving the new question at the bottom until the first + // token arrives. this._isLoading = true; + this._renderMessages({ anchor: true, instant: true }); const sendBtn = this._panel.querySelector('.bookslm-btn-send'); if (sendBtn) sendBtn.disabled = true; diff --git a/frontend/style.css b/frontend/style.css index d495704..4829bb7 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9440,8 +9440,11 @@ body.popup-mode .content-area { .bookslm-messages { flex: 1; overflow-y: auto; padding: 20px 16px; display: flex; flex-direction: column; gap: 24px; } .bookslm-msg { display: flex; flex-direction: column; gap: 4px; scroll-margin-top: 14px; } -.bookslm-msg.user { align-self: flex-end; max-width: 80%; } -.bookslm-msg.assistant { align-self: stretch; width: 100%; } +/* The user bubble is capped against the *thread* width, not against a + shrink-to-fit wrapper: capping both would compound (80% × 85% ≈ 68%) and + produced very short lines. Wrapper stretches, bubble carries the cap. */ +.bookslm-msg.user { align-items: flex-end; } +.bookslm-msg.assistant { align-items: stretch; width: 100%; } .bookslm-msg-meta { font-size: 11px; color: var(--text-secondary); opacity: 0.75; padding: 0 2px; align-self: flex-start; user-select: none; } /* Hover-revealed action bar (Copier / Ajouter). */ @@ -9456,7 +9459,8 @@ body.popup-mode .content-area { .bookslm-msg-action:hover { background: var(--surface2); color: var(--text-primary); border-color: var(--border); } .bookslm-bubble { max-width: 85%; padding: 10px 14px; border-radius: 18px; font-size: 14px; line-height: 1.5; word-wrap: break-word; } -.bookslm-bubble.user { align-self: flex-end; background: var(--surface2); color: var(--text-primary); border-bottom-right-radius: 6px; } +.bookslm-bubble.user { align-self: flex-end; max-width: 90%; min-width: 0; + background: var(--surface2); color: var(--text-primary); border-bottom-right-radius: 6px; } .bookslm-bubble.assistant { align-self: flex-start; background: transparent; padding: 0; width: 100%; max-width: 100%; color: var(--text-primary); } .bookslm-bubble.assistant code { background: rgba(0,0,0,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.9em; } .bookslm-bubble.assistant pre { background: rgba(0,0,0,0.3); padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; } diff --git a/frontend/sw.js b/frontend/sw.js index 81040df..5668006 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 = 'v10'; +const SW_VERSION = 'v12'; 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 f9bb1c7..f853961 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -235,6 +235,39 @@ async function main() { panel.remove(); }); + await test("the placeholder render keeps the anchor (no scroll reset before first token)", async () => { + // Regression: the assistant placeholder used to be rendered while + // `_isLoading` was still false, so it took the "restore previous + // scroll" branch and cancelled the anchor — the submitted post stayed + // at the bottom until the first streamed token. + const b = new BooksLM(); + const panel = b._render(); + b._panel = panel; + document.body.appendChild(panel); + const renders = []; + const realRender = b._renderMessages.bind(b); + b._renderMessages = (opts) => { renders.push(opts || {}); realRender(opts || {}); }; + b._setActivity = () => {}; + b._saveHistory = () => {}; + b._postChat = async () => ({ + ok: true, + status: 200, + body: { getReader: () => ({ read: async () => ({ done: true }) }) }, + }); + panel.querySelector("textarea").value = "Question ancrée"; + await b._sendMessage(); + // Two renders happen on send: the user post and the assistant + // placeholder — both must request the anchor. + assert.ok(renders.length >= 2, `expected >=2 renders, got ${renders.length}`); + assert.equal(renders[0].anchor, true, "user post render anchors"); + assert.equal(renders[1].anchor, true, "placeholder render anchors too"); + // Both must be instant: the question has to be at the top when the + // answer starts arriving, not after a scroll animation. + assert.equal(renders[0].instant, true, "user post anchors instantly"); + assert.equal(renders[1].instant, true, "placeholder anchors instantly"); + panel.remove(); + }); + await test("assistant answer shows a discreet provider/model tag", () => { const b = new BooksLM(); const panel = b._render();