diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index ab81304..e5d3a93 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1797,6 +1797,14 @@ html[data-files="1"] #files-btn{color:var(--accent)} color:var(--dim);letter-spacing:-.01em;overflow:visible} .side .preset:hover .conv-when{opacity:0} .conv-empty{font-size:12px;color:var(--dim);padding:8px 11px} +/* Extrait d'un résultat de recherche plein-texte : sous le titre, à la place + de .preset-meta, une seule ligne tronquée comme le titre. */ +.side .preset .conv-snip{font-size:12px;color:var(--dim);margin-top:3px;white-space:nowrap; + overflow:hidden;text-overflow:ellipsis} +/* Ligne à extrait : deux lignes de texte. L'heure et l'anneau se calent sur + la ligne de base du TITRE au lieu de se centrer entre les deux (les boutons, + en position absolue, restent centrés sur la ligne entière). */ +.side .preset.has-snip{align-items:baseline} .conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none; border:0;padding:8px 11px;text-align:left;cursor:pointer} .conv-more:hover{color:var(--text)} @@ -2351,11 +2359,12 @@ html[data-files="1"] #files-btn{color:var(--accent)} + La recherche filtre d'abord les titres CÔTÉ CLIENT (la liste est déjà + chargée, réponse immédiate), puis demande au serveur les discussions dont + le TEXTE contient les mots (index plein-texte, chat_search.go) — avec un + extrait sous le titre. -->
Discussions - +
@@ -10528,8 +10537,11 @@ function fmtWhen(sec){ // balisage ni les gestionnaires d'événement. // // La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre -// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant, -// titre, dates, nombre d'échanges — jamais les messages. +// les TITRES sans aller-retour serveur : elle ne porte que des métadonnées — +// identifiant, titre, dates, nombre d'échanges — jamais les messages. Le TEXTE +// des discussions, lui, est cherché par le serveur (/api/conversations/search, +// index plein-texte chiffré comme les discussions) : sa réponse arrive après +// une courte pause de frappe et complète la liste, avec un extrait. let CONVS = [], CONV_ACTIVE = ''; // Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au @@ -10543,11 +10555,65 @@ let CONV_BUSY = false; // recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire). const CONV_PAGE = 40; let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null; +// Recherche plein-texte : dernière réponse du serveur (pour la requête q), et +// minuterie de la pause de frappe. Une réponse arrivée pour une requête qui +// n'est plus celle du champ est jetée. +// convSrvGen numérote les requêtes : un rechargement de la liste en relance +// une pour la même saisie, la réponse d'avant ne doit plus s'afficher. +let CONV_SRV = null, convSrvTimer = 0, convSrvWanted = '', convSrvGen = 0; +const CONV_SRV_PAUSE = 250; + +function convSearchSchedule(q){ + if(q === convSrvWanted) return; + convSrvWanted = q; + clearTimeout(convSrvTimer); + const gen = ++convSrvGen; + if(!q) return; + convSrvTimer = setTimeout(async () => { + let r; + try{ r = await jget('/api/conversations/search?limit=100&q=' + encodeURIComponent(q)); }catch(_){ r = null; } + if(gen !== convSrvGen) return; // saisie changée, ou liste rechargée depuis + CONV_SRV = {q, r: (r && r.ok) ? r : null, failed: !(r && r.ok)}; + renderConversations(); + }, CONV_SRV_PAUSE); +} + +// Liste affichée pour une requête : les résultats du serveur dans leur ordre +// (pertinence : titre puis texte), puis les titres trouvés ici que le serveur +// n'a pas (il cherche des débuts de mots, le filtre local une sous-chaîne). +// Rend aussi les extraits et la ligne d'état éventuelle. +function convSearchList(q){ + const byTitle = CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q)); + const snips = new Map(); + const cur = CONV_SRV && CONV_SRV.q === q ? CONV_SRV : null; + const srv = cur ? cur.r : null; + if(!srv) return {list: byTitle, snips, note: cur && cur.failed ? 'recherche dans le texte indisponible : titres seulement' : ''}; + let note = ''; + const got = (srv.conversations || []).length; + if(srv.state === 'locked') note = 'mémoire verrouillée : recherche dans les titres seulement'; + else if(srv.indexed < srv.indexed_total) note = 'indexation en cours (' + srv.indexed + '/' + srv.indexed_total + ') : résultats partiels'; + else if(srv.total > got) note = 'les ' + got + ' plus pertinentes sur ' + srv.total + ' : précisez la recherche'; + const byID = new Map(CONVS.map(c => [c.id, c])); + const list = [], seen = new Set(); + for(const h of (srv.conversations || [])){ + const c = byID.get(h.id); + if(!c || seen.has(c.id)) continue; + seen.add(c.id); + list.push(c); + if(h.snippet && h.snippet !== (c.title || '')) snips.set(c.id, h.snippet); + } + for(const c of byTitle) if(!seen.has(c.id)) list.push(c); + return {list, snips, note}; +} async function loadConversations(){ let r; try{ r = await jget('/api/conversations'); }catch(_){ return; } CONVS = r.conversations || []; + // La liste a changé (fin de tour, bascule, suppression) : une recherche en + // cours est refaite. Ses résultats restent affichés d'ici la nouvelle + // réponse — les effacer ferait clignoter la liste à chaque tour. + convSrvWanted = ''; // Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place // dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas // remonter (le serveur, lui, trie par activité). Départage par l'ordre du @@ -10566,9 +10632,14 @@ function renderConversations(){ const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase(); cont.textContent = ''; if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; } - const list = q - ? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q)) - : CONVS; + convSearchSchedule(q); + const {list, snips, note} = q ? convSearchList(q) : {list: CONVS, snips: new Map(), note: ''}; + if(note){ + const e = document.createElement('div'); + e.className = 'conv-empty'; + e.textContent = note; + cont.appendChild(e); + } if(!list.length){ const e = document.createElement('div'); e.className = 'conv-empty'; @@ -10583,7 +10654,8 @@ function renderConversations(){ const shown = Math.max(CONV_SHOWN, ai + 1); for(const c of list.slice(0, shown)){ const row = document.createElement('div'); - row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : ''); + const sn = snips.get(c.id); + row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : ''); // L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE // qui travaille sans redessiner la liste (un redessin relancerait // l'animation de l'anneau à chaque événement du flux). @@ -10596,6 +10668,14 @@ function renderConversations(){ nm.appendChild(document.createTextNode(title)); nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : ''); info.appendChild(nm); + // Recherche : le passage du texte où les mots ont été trouvés. + if(sn){ + const s = document.createElement('div'); + s.className = 'conv-snip'; + s.textContent = sn; + s.title = sn; + info.appendChild(s); + } // Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au // survol : renommer et supprimer prennent sa place, la ligne ne s'allonge diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 91dc1a7..3fb2ab0 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -66,11 +66,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid + La recherche filtre d'abord les titres CÔTÉ CLIENT (la liste est déjà + chargée, réponse immédiate), puis demande au serveur les discussions dont + le TEXTE contient les mots (index plein-texte, chat_search.go) — avec un + extrait sous le titre. -->
Discussions - +
diff --git a/internal/loki/ui/src/js/16-conversations.js b/internal/loki/ui/src/js/16-conversations.js index 48dd777..809e75a 100644 --- a/internal/loki/ui/src/js/16-conversations.js +++ b/internal/loki/ui/src/js/16-conversations.js @@ -10,8 +10,11 @@ // balisage ni les gestionnaires d'événement. // // La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre -// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant, -// titre, dates, nombre d'échanges — jamais les messages. +// les TITRES sans aller-retour serveur : elle ne porte que des métadonnées — +// identifiant, titre, dates, nombre d'échanges — jamais les messages. Le TEXTE +// des discussions, lui, est cherché par le serveur (/api/conversations/search, +// index plein-texte chiffré comme les discussions) : sa réponse arrive après +// une courte pause de frappe et complète la liste, avec un extrait. let CONVS = [], CONV_ACTIVE = ''; // Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au @@ -25,11 +28,65 @@ let CONV_BUSY = false; // recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire). const CONV_PAGE = 40; let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null; +// Recherche plein-texte : dernière réponse du serveur (pour la requête q), et +// minuterie de la pause de frappe. Une réponse arrivée pour une requête qui +// n'est plus celle du champ est jetée. +// convSrvGen numérote les requêtes : un rechargement de la liste en relance +// une pour la même saisie, la réponse d'avant ne doit plus s'afficher. +let CONV_SRV = null, convSrvTimer = 0, convSrvWanted = '', convSrvGen = 0; +const CONV_SRV_PAUSE = 250; + +function convSearchSchedule(q){ + if(q === convSrvWanted) return; + convSrvWanted = q; + clearTimeout(convSrvTimer); + const gen = ++convSrvGen; + if(!q) return; + convSrvTimer = setTimeout(async () => { + let r; + try{ r = await jget('/api/conversations/search?limit=100&q=' + encodeURIComponent(q)); }catch(_){ r = null; } + if(gen !== convSrvGen) return; // saisie changée, ou liste rechargée depuis + CONV_SRV = {q, r: (r && r.ok) ? r : null, failed: !(r && r.ok)}; + renderConversations(); + }, CONV_SRV_PAUSE); +} + +// Liste affichée pour une requête : les résultats du serveur dans leur ordre +// (pertinence : titre puis texte), puis les titres trouvés ici que le serveur +// n'a pas (il cherche des débuts de mots, le filtre local une sous-chaîne). +// Rend aussi les extraits et la ligne d'état éventuelle. +function convSearchList(q){ + const byTitle = CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q)); + const snips = new Map(); + const cur = CONV_SRV && CONV_SRV.q === q ? CONV_SRV : null; + const srv = cur ? cur.r : null; + if(!srv) return {list: byTitle, snips, note: cur && cur.failed ? 'recherche dans le texte indisponible : titres seulement' : ''}; + let note = ''; + const got = (srv.conversations || []).length; + if(srv.state === 'locked') note = 'mémoire verrouillée : recherche dans les titres seulement'; + else if(srv.indexed < srv.indexed_total) note = 'indexation en cours (' + srv.indexed + '/' + srv.indexed_total + ') : résultats partiels'; + else if(srv.total > got) note = 'les ' + got + ' plus pertinentes sur ' + srv.total + ' : précisez la recherche'; + const byID = new Map(CONVS.map(c => [c.id, c])); + const list = [], seen = new Set(); + for(const h of (srv.conversations || [])){ + const c = byID.get(h.id); + if(!c || seen.has(c.id)) continue; + seen.add(c.id); + list.push(c); + if(h.snippet && h.snippet !== (c.title || '')) snips.set(c.id, h.snippet); + } + for(const c of byTitle) if(!seen.has(c.id)) list.push(c); + return {list, snips, note}; +} async function loadConversations(){ let r; try{ r = await jget('/api/conversations'); }catch(_){ return; } CONVS = r.conversations || []; + // La liste a changé (fin de tour, bascule, suppression) : une recherche en + // cours est refaite. Ses résultats restent affichés d'ici la nouvelle + // réponse — les effacer ferait clignoter la liste à chaque tour. + convSrvWanted = ''; // Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place // dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas // remonter (le serveur, lui, trie par activité). Départage par l'ordre du @@ -48,9 +105,14 @@ function renderConversations(){ const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase(); cont.textContent = ''; if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; } - const list = q - ? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q)) - : CONVS; + convSearchSchedule(q); + const {list, snips, note} = q ? convSearchList(q) : {list: CONVS, snips: new Map(), note: ''}; + if(note){ + const e = document.createElement('div'); + e.className = 'conv-empty'; + e.textContent = note; + cont.appendChild(e); + } if(!list.length){ const e = document.createElement('div'); e.className = 'conv-empty'; @@ -65,7 +127,8 @@ function renderConversations(){ const shown = Math.max(CONV_SHOWN, ai + 1); for(const c of list.slice(0, shown)){ const row = document.createElement('div'); - row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : ''); + const sn = snips.get(c.id); + row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : ''); // L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE // qui travaille sans redessiner la liste (un redessin relancerait // l'animation de l'anneau à chaque événement du flux). @@ -78,6 +141,14 @@ function renderConversations(){ nm.appendChild(document.createTextNode(title)); nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : ''); info.appendChild(nm); + // Recherche : le passage du texte où les mots ont été trouvés. + if(sn){ + const s = document.createElement('div'); + s.className = 'conv-snip'; + s.textContent = sn; + s.title = sn; + info.appendChild(s); + } // Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au // survol : renommer et supprimer prennent sa place, la ligne ne s'allonge diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 6280b09..5572935 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -1762,6 +1762,14 @@ html[data-files="1"] #files-btn{color:var(--accent)} color:var(--dim);letter-spacing:-.01em;overflow:visible} .side .preset:hover .conv-when{opacity:0} .conv-empty{font-size:12px;color:var(--dim);padding:8px 11px} +/* Extrait d'un résultat de recherche plein-texte : sous le titre, à la place + de .preset-meta, une seule ligne tronquée comme le titre. */ +.side .preset .conv-snip{font-size:12px;color:var(--dim);margin-top:3px;white-space:nowrap; + overflow:hidden;text-overflow:ellipsis} +/* Ligne à extrait : deux lignes de texte. L'heure et l'anneau se calent sur + la ligne de base du TITRE au lieu de se centrer entre les deux (les boutons, + en position absolue, restent centrés sur la ligne entière). */ +.side .preset.has-snip{align-items:baseline} .conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none; border:0;padding:8px 11px;text-align:left;cursor:pointer} .conv-more:hover{color:var(--text)}