fix(assistant): #91 bulle utilisateur elargie (81% du fil) + ancrage de la question des l'envoi ; docs: feuille de route outils #92
This commit is contained in:
@@ -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
|
||||
|
||||
+18
-1
@@ -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** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.<key>` 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.
|
||||
@@ -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;
|
||||
|
||||
+7
-3
@@ -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; }
|
||||
|
||||
+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 = '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}`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user