diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 780c9fe..0bdc798 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -71,7 +71,9 @@ var conv = func() *Conversation { // LoadConversation recharge l'état persisté au démarrage du process. Sans état // enregistré (première fois) on part d'une conversation vide. func LoadConversation() { - b := getBytes(bkChat, "conversation") + // convEnsureActive reprend au passage le fil unique des versions + // précédentes (clé « conversation ») comme première discussion. + b := getBytes(bkChat, convKey(convEnsureActive())) if len(b) == 0 { return } @@ -83,16 +85,44 @@ func LoadConversation() { conv.cancel = nil } +// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil +// neuf) et invalide les abonnés : l'epoch incrémenté leur fait vider l'écran et +// rejouer depuis zéro, exactement comme un reset. L'appelant NE doit PAS +// détenir mu. +func (c *Conversation) loadFrom(b []byte) { + c.Stop() + c.mu.Lock() + c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0 + if len(b) > 0 { + _ = json.Unmarshal(b, c) + } + c.Generating = false + c.cancel = nil + c.epoch++ + c.cond.Broadcast() + c.mu.Unlock() +} + // persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque -// delta). L'appelant NE doit PAS détenir mu. +// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit +// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu. func (c *Conversation) persist() { c.mu.Lock() b, err := json.Marshal(c) + title := convSummary(c.Messages) + turns := 0 + for _, ev := range c.Log { + if _, ok := ev.Delta["user"]; ok { + turns++ + } + } c.mu.Unlock() if err != nil { return } - _ = putBytes(bkChat, "conversation", b) + id := convEnsureActive() + _ = putBytes(bkChat, convKey(id), b) + convTouchMeta(id, title, turns) } // appendDelta journalise un événement d'affichage et réveille les abonnés. diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go new file mode 100644 index 0000000..ee5c03b --- /dev/null +++ b/internal/loki/chat_sessions.go @@ -0,0 +1,224 @@ +package loki + +// chat_sessions.go — plusieurs discussions au lieu d'un fil unique. +// +// L'amont ne connaît QU'UNE conversation, persistée sous la clé +// bkChat/"conversation" : « clear chat » l'effaçait définitivement. Ici on garde +// exactement la même machinerie (un seul `conv` en mémoire, mêmes flux SSE, +// même compactage) mais on la range dans un tiroir nommé : +// +// bkChat/index → liste des discussions (métadonnées, sans les messages) +// bkChat/active → identifiant de la discussion ouverte +// bkChat/conv: → l'état complet d'une discussion (JSON de Conversation) +// +// Basculer = enregistrer la discussion courante, charger l'autre en mémoire et +// incrémenter l'epoch : les abonnés SSE reçoivent alors {reset:true} et +// rejouent le nouveau fil depuis zéro. C'est le mécanisme déjà utilisé par +// Reset(), donc aucun code de rendu ni de flux à toucher. +// +// La discussion active est PARTAGÉE par tous les appareils, comme le fil unique +// de l'amont : changer de discussion sur le téléphone la change aussi sur le PC. + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +const ( + ckIndex = "index" + ckActive = "active" + ckLegacy = "conversation" // fil unique d'avant le multi-discussions +) + +// convMeta décrit une discussion SANS ses messages : c'est ce que liste l'UI. +type convMeta struct { + ID string `json:"id"` + Title string `json:"title"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` + Turns int `json:"turns"` +} + +func convKey(id string) string { return "conv:" + id } + +func convIndex() []convMeta { + var idx []convMeta + if b := getBytes(bkChat, ckIndex); len(b) > 0 { + _ = json.Unmarshal(b, &idx) + } + // Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de + // discussions, et l'UI n'a rien à trier. + sort.SliceStable(idx, func(i, j int) bool { return idx[i].Updated > idx[j].Updated }) + return idx +} + +func convIndexSave(idx []convMeta) { + b, err := json.Marshal(idx) + if err != nil { + return + } + _ = putBytes(bkChat, ckIndex, b) +} + +// convEnsureActive garantit qu'une discussion active existe, et reprend le fil +// unique de l'amont s'il y en a un (migration silencieuse, une seule fois). +func convEnsureActive() string { + if id := getStr(bkChat, ckActive); id != "" { + return id + } + id := newConvID() + if legacy := getBytes(bkChat, ckLegacy); len(legacy) > 0 { + // Migration : le fil unique devient la première discussion. On garde la + // clé d'origine intacte — en cas de retour arrière, rien n'est perdu. + _ = putBytes(bkChat, convKey(id), legacy) + } + _ = putStr(bkChat, ckActive, id) + now := time.Now().Unix() + convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now})) + return id +} + +// newConvID : horodatage en nanosecondes — monotone, lisible au tri, et sans +// dépendance à un générateur aléatoire. +func newConvID() string { return fmt.Sprintf("c%d", time.Now().UnixNano()) } + +// convTouchMeta rafraîchit les métadonnées de la discussion active après un +// enregistrement : titre (déduit du premier message si l'utilisateur n'en a pas +// choisi), date de modification et nombre d'échanges. +func convTouchMeta(id string, title string, turns int) { + idx := convIndex() + now := time.Now().Unix() + for i := range idx { + if idx[i].ID != id { + continue + } + idx[i].Updated = now + idx[i].Turns = turns + if idx[i].Title == "" { + idx[i].Title = title + } + convIndexSave(idx) + return + } + convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns})) +} + +// convSummary lit le premier message utilisateur pour en faire un titre. Sans +// message, on laisse vide : l'UI affiche « Nouvelle discussion » et le titre se +// posera tout seul au premier échange. +func convSummary(msgs []Message) string { + for _, m := range msgs { + if m.Role != "user" { + continue + } + s, _ := m.Content.(string) + s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) + if s == "" { + continue + } + // Découpe sur les RUNES : tronquer des octets couperait un caractère + // accentué en deux et produirait un titre invalide. + if r := []rune(s); len(r) > 60 { + s = strings.TrimSpace(string(r[:60])) + "…" + } + return s + } + return "" +} + +// ConvList renvoie les discussions et l'identifiant de l'active (pour l'UI). +func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() } + +// convSwitch bascule sur une autre discussion : la courante est enregistrée, +// la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients +// rejouent le nouveau fil. +func convSwitch(id string) error { + if id == "" { + return fmt.Errorf("identifiant manquant") + } + found := false + for _, m := range convIndex() { + if m.ID == id { + found = true + break + } + } + if !found { + return fmt.Errorf("discussion introuvable") + } + if id == getStr(bkChat, ckActive) { + return nil + } + conv.persist() // fige la discussion qu'on quitte + _ = putStr(bkChat, ckActive, id) + conv.loadFrom(getBytes(bkChat, convKey(id))) + return nil +} + +// convCreate crée une discussion vide et l'ouvre, SANS enregistrer la courante. +// Séparé de convNew parce que la suppression de la dernière discussion doit +// repartir d'un fil neuf sans ressusciter celui qu'on vient d'effacer. +func convCreate() string { + id := newConvID() + now := time.Now().Unix() + convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now})) + _ = putStr(bkChat, ckActive, id) + conv.loadFrom(nil) + return id +} + +// convNew met de côté la discussion courante puis en ouvre une neuve. +func convNew() string { + conv.persist() + return convCreate() +} + +func convRename(id, title string) error { + title = strings.TrimSpace(title) + if id == "" || title == "" { + return fmt.Errorf("identifiant ou titre manquant") + } + idx := convIndex() + for i := range idx { + if idx[i].ID == id { + idx[i].Title = title + convIndexSave(idx) + return nil + } + } + return fmt.Errorf("discussion introuvable") +} + +// convDelete supprime une discussion. Supprimer celle qui est ouverte bascule +// sur la plus récente restante — ou sur une discussion neuve s'il n'en reste +// aucune : il y a TOUJOURS une discussion active. +func convDelete(id string) error { + idx := convIndex() + next := make([]convMeta, 0, len(idx)) + found := false + for _, m := range idx { + if m.ID == id { + found = true + continue + } + next = append(next, m) + } + if !found { + return fmt.Errorf("discussion introuvable") + } + convIndexSave(next) + _ = putBytes(bkChat, convKey(id), nil) + if id != getStr(bkChat, ckActive) { + return nil + } + if len(next) == 0 { + convCreate() // surtout pas convNew : il réenregistrerait la supprimée + return nil + } + _ = putStr(bkChat, ckActive, next[0].ID) + conv.loadFrom(getBytes(bkChat, convKey(next[0].ID))) + return nil +} diff --git a/internal/loki/sys_update.go b/internal/loki/sys_update.go index 7147ffb..a28d42b 100644 --- a/internal/loki/sys_update.go +++ b/internal/loki/sys_update.go @@ -239,6 +239,10 @@ func cmdUpdate(args []string) error { checkOnly = true } } + if containerManaged() { + fmt.Println(containerUpdateNote) + return nil + } fmt.Println("recherche de la dernière version…") info, err := checkForUpdate() if err != nil { @@ -422,8 +426,21 @@ func cleanupOldBinary() { removeOldBinaries(filepath.Join(filepath.Dir(exe), "loki.exe")) } +// containerManaged : le binaire appartient à l'image Docker, la mise à jour +// passe donc par elle (docker compose pull) et non par un remplacement du +// fichier — que l'image, en lecture seule à l'exécution, ne permettrait pas. +// Sans ce garde-fou, l'UI interrogeait les releases GitHub du dépôt du fork, +// qui n'en publie aucune : « GitHub a répondu 404 Not Found ». +func containerManaged() bool { return os.Getenv("LOKI_CONTAINER") == "1" } + +const containerUpdateNote = "Mise à jour par l'image Docker : docker compose pull && docker compose up -d" + // handleUpdateCheck (GET /api/update) : renvoie l'état de mise à jour pour l'UI. func handleUpdateCheck(w http.ResponseWriter, r *http.Request) { + if containerManaged() { + sendJSON(w, 200, map[string]any{"current": Version, "available": false, "note": containerUpdateNote}) + return + } info, err := checkForUpdate() if err != nil { sendJSON(w, 200, map[string]any{"current": Version, "available": false, "error": err.Error()}) @@ -437,6 +454,10 @@ func handleUpdateCheck(w http.ResponseWriter, r *http.Request) { // d'UI (loki-ui : il sert l'UI, le chat et le tunnel) pour appliquer la MAJ sans // avoir à SSH — voir restartAfterUpdate. Renvoie la version + le message de statut. func handleUpdateApply(w http.ResponseWriter, r *http.Request) { + if containerManaged() { + sendJSON(w, 409, map[string]any{"ok": false, "error": containerUpdateNote}) + return + } newVer, err := applyUpdate() if err != nil { // 500 : un client script peut tester le statut HTTP ; l'UI, elle, lit le JSON. diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 5fd1a05..135758e 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -139,6 +139,13 @@ h1{margin:0;font-size:18px;color:var(--accent)} .brand-fork{font-size:10px;color:var(--dim);margin:0 0 14px;line-height:1.3} .brand-fork a{color:var(--dim);text-decoration:underline;text-underline-offset:2px} .brand-fork a:hover{color:var(--accent)} +/* Discussions : mêmes lignes que les presets, plus deux actions au survol. + Elles restent visibles au clavier (:focus-within) pour rester atteignables + sans souris. */ +.conv-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s} +.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1} +.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer} +.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)} /* pastille d'état service, en haut à droite de la barre latérale */ .statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer} .statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)} @@ -1213,6 +1220,12 @@ button:hover{border-color:var(--dim);color:var(--text)} …
fork de AJEAN
+ +
Discussions +
+
@@ -2354,7 +2367,10 @@ async function checkUpdate(){ b.disabled=true; msg.textContent='Vérification…'; try{ const r=await jget('/api/update'); - if(r.error){ msg.textContent='Erreur : '+r.error; } + // En conteneur, la mise à jour passe par l'image : on l'explique au lieu de + // proposer un remplacement de binaire impossible ici. + if(r.note){ msg.textContent=r.note; } + else if(r.error){ msg.textContent='Erreur : '+r.error; } else if(r.available){ msg.innerHTML='Nouvelle version v'+r.latest+' disponible. '; const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate; @@ -2961,7 +2977,7 @@ async function loadAll(){ // allSettled et pas all : un seul chargement en échec (accès distant coupé, // clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les // hauteurs réservées en place pour toujours. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } @@ -4475,7 +4491,10 @@ function handleDelta(d){ // déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil. syncChatEmpty(); return; } - if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; } + // `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté + // serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule + // a pu être déclenchée depuis un autre appareil. + if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; } if(d.user!==undefined){ newTurn(); let el=PENDING; @@ -4485,7 +4504,10 @@ function handleDelta(d){ // bulle neuve — sinon elles apparaîtraient en double. if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files); setBusy(true); T.typingEl=addTyping(); return; } - if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; } + // Fin de tour : la discussion vient d'être enregistrée côté serveur — son + // titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au + // replay, qui rejoue tous les tours passés d'un bloc. + if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; } if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } if(d.compacting!==undefined){ setCompacting(d.compacting); return; } if(d.compacted){ setCompacting(false); addCompactMark(); return; } @@ -5727,4 +5749,99 @@ async function revokeNode(id){ await jpost('/api/node/revoke', {id}); hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué'); } +// Discussions — plusieurs fils au lieu de la conversation unique d'origine. +// +// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils : +// basculer ici bascule partout. Le changement arrive donc par le flux SSE (un +// événement `reset`, comme pour « vider »), et c'est lui qui redessine le chat — +// on ne fait que rafraîchir la liste. +// +// Les titres viennent du premier message de l'utilisateur : ils sont construits +// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le +// balisage ni les gestionnaires d'événement. + +async function loadConversations(){ + let r; + try{ r = await jget('/api/conversations'); }catch(_){ return; } + const cont = document.getElementById('conv-list'); + if(!cont) return; + cont.innerHTML = ''; + const list = r.conversations || []; + if(!list.length){ cont.innerHTML = '(aucune)'; return; } + for(const c of list){ + const row = document.createElement('div'); + row.className = 'preset' + (c.id === r.active ? ' active' : ''); + row.onclick = () => convSwitch(c.id); + + const info = document.createElement('div'); info.className = 'preset-info'; + const nm = document.createElement('div'); nm.className = 'preset-name'; + if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); } + const title = c.title || 'Nouvelle discussion'; + nm.appendChild(document.createTextNode(title)); + nm.title = title; + const meta = document.createElement('div'); meta.className = 'preset-meta'; + const when = document.createElement('span'); when.className = 'muted'; + when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : ''); + meta.appendChild(when); + info.appendChild(nm); info.appendChild(meta); + + // Renommer / supprimer : stopPropagation, sinon le clic bascule aussi. + const acts = document.createElement('div'); acts.className = 'conv-acts'; + const ren = document.createElement('button'); ren.className = 'iconbtn'; ren.textContent = '✎'; + ren.title = 'renommer'; ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); }; + const del = document.createElement('button'); del.className = 'iconbtn'; del.textContent = '🗑'; + del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); }; + acts.appendChild(ren); acts.appendChild(del); + + row.appendChild(info); row.appendChild(acts); + cont.appendChild(row); + } +} + +// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de +// discussions se lit d'un coup d'œil, pas au timestamp complet. +function convWhen(unix){ + if(!unix) return ''; + const d = new Date(unix * 1000), now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + return sameDay + ? d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}) + : d.toLocaleDateString([], {day:'2-digit', month:'2-digit'}); +} + +async function convNew(){ + try{ + await jpost('/api/conversations/new', {}); + toast('nouvelle discussion'); + loadConversations(); + }catch(_){ toast('erreur réseau'); } +} + +async function convSwitch(id){ + try{ + const r = await jpost('/api/conversations/switch', {id}); + if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; } + loadConversations(); + }catch(_){ toast('erreur réseau'); } +} + +async function convRename(id, current){ + const t = await askPrompt('Nouveau titre de la discussion', {title:'Renommer', default:current, okText:'Renommer'}); + if(!t) return; + try{ + const r = await jpost('/api/conversations/rename', {id, title:t}); + if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; } + loadConversations(); + }catch(_){ toast('erreur réseau'); } +} + +async function convDelete(id, title){ + if(!await askConfirm('Supprimer « ' + title + ' » ? Cette discussion et son historique seront perdus.', + {title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return; + try{ + const r = await jpost('/api/conversations/delete', {id}); + if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; } + loadConversations(); + }catch(_){ toast('erreur réseau'); } +} diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 884a2e0..f69057e 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -38,6 +38,12 @@ …
fork de AJEAN
+ +
Discussions +
+
diff --git a/internal/loki/ui/src/js/05-status.js b/internal/loki/ui/src/js/05-status.js index 9fce8e1..9a44b85 100644 --- a/internal/loki/ui/src/js/05-status.js +++ b/internal/loki/ui/src/js/05-status.js @@ -59,7 +59,10 @@ async function checkUpdate(){ b.disabled=true; msg.textContent='Vérification…'; try{ const r=await jget('/api/update'); - if(r.error){ msg.textContent='Erreur : '+r.error; } + // En conteneur, la mise à jour passe par l'image : on l'explique au lieu de + // proposer un remplacement de binaire impossible ici. + if(r.note){ msg.textContent=r.note; } + else if(r.error){ msg.textContent='Erreur : '+r.error; } else if(r.available){ msg.innerHTML='Nouvelle version v'+r.latest+' disponible. '; const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate; diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js index 2373e56..8584323 100644 --- a/internal/loki/ui/src/js/06-settings.js +++ b/internal/loki/ui/src/js/06-settings.js @@ -491,7 +491,7 @@ async function loadAll(){ // allSettled et pas all : un seul chargement en échec (accès distant coupé, // clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les // hauteurs réservées en place pour toujours. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index e3dfc77..8822b01 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -152,7 +152,10 @@ function handleDelta(d){ // déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil. syncChatEmpty(); return; } - if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; } + // `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté + // serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule + // a pu être déclenchée depuis un autre appareil. + if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; } if(d.user!==undefined){ newTurn(); let el=PENDING; @@ -162,7 +165,10 @@ function handleDelta(d){ // bulle neuve — sinon elles apparaîtraient en double. if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files); setBusy(true); T.typingEl=addTyping(); return; } - if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; } + // Fin de tour : la discussion vient d'être enregistrée côté serveur — son + // titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au + // replay, qui rejoue tous les tours passés d'un bloc. + if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; } if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } if(d.compacting!==undefined){ setCompacting(d.compacting); return; } if(d.compacted){ setCompacting(false); addCompactMark(); return; } diff --git a/internal/loki/ui/src/js/16-conversations.js b/internal/loki/ui/src/js/16-conversations.js new file mode 100644 index 0000000..a47e9ff --- /dev/null +++ b/internal/loki/ui/src/js/16-conversations.js @@ -0,0 +1,95 @@ +// Discussions — plusieurs fils au lieu de la conversation unique d'origine. +// +// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils : +// basculer ici bascule partout. Le changement arrive donc par le flux SSE (un +// événement `reset`, comme pour « vider »), et c'est lui qui redessine le chat — +// on ne fait que rafraîchir la liste. +// +// Les titres viennent du premier message de l'utilisateur : ils sont construits +// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le +// balisage ni les gestionnaires d'événement. + +async function loadConversations(){ + let r; + try{ r = await jget('/api/conversations'); }catch(_){ return; } + const cont = document.getElementById('conv-list'); + if(!cont) return; + cont.innerHTML = ''; + const list = r.conversations || []; + if(!list.length){ cont.innerHTML = '(aucune)'; return; } + for(const c of list){ + const row = document.createElement('div'); + row.className = 'preset' + (c.id === r.active ? ' active' : ''); + row.onclick = () => convSwitch(c.id); + + const info = document.createElement('div'); info.className = 'preset-info'; + const nm = document.createElement('div'); nm.className = 'preset-name'; + if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); } + const title = c.title || 'Nouvelle discussion'; + nm.appendChild(document.createTextNode(title)); + nm.title = title; + const meta = document.createElement('div'); meta.className = 'preset-meta'; + const when = document.createElement('span'); when.className = 'muted'; + when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : ''); + meta.appendChild(when); + info.appendChild(nm); info.appendChild(meta); + + // Renommer / supprimer : stopPropagation, sinon le clic bascule aussi. + const acts = document.createElement('div'); acts.className = 'conv-acts'; + const ren = document.createElement('button'); ren.className = 'iconbtn'; ren.textContent = '✎'; + ren.title = 'renommer'; ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); }; + const del = document.createElement('button'); del.className = 'iconbtn'; del.textContent = '🗑'; + del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); }; + acts.appendChild(ren); acts.appendChild(del); + + row.appendChild(info); row.appendChild(acts); + cont.appendChild(row); + } +} + +// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de +// discussions se lit d'un coup d'œil, pas au timestamp complet. +function convWhen(unix){ + if(!unix) return ''; + const d = new Date(unix * 1000), now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + return sameDay + ? d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}) + : d.toLocaleDateString([], {day:'2-digit', month:'2-digit'}); +} + +async function convNew(){ + try{ + await jpost('/api/conversations/new', {}); + toast('nouvelle discussion'); + loadConversations(); + }catch(_){ toast('erreur réseau'); } +} + +async function convSwitch(id){ + try{ + const r = await jpost('/api/conversations/switch', {id}); + if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; } + loadConversations(); + }catch(_){ toast('erreur réseau'); } +} + +async function convRename(id, current){ + const t = await askPrompt('Nouveau titre de la discussion', {title:'Renommer', default:current, okText:'Renommer'}); + if(!t) return; + try{ + const r = await jpost('/api/conversations/rename', {id, title:t}); + if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; } + loadConversations(); + }catch(_){ toast('erreur réseau'); } +} + +async function convDelete(id, title){ + if(!await askConfirm('Supprimer « ' + title + ' » ? Cette discussion et son historique seront perdus.', + {title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return; + try{ + const r = await jpost('/api/conversations/delete', {id}); + if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; } + loadConversations(); + }catch(_){ toast('erreur réseau'); } +} diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 9eb328f..c10e067 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -113,6 +113,13 @@ h1{margin:0;font-size:18px;color:var(--accent)} .brand-fork{font-size:10px;color:var(--dim);margin:0 0 14px;line-height:1.3} .brand-fork a{color:var(--dim);text-decoration:underline;text-underline-offset:2px} .brand-fork a:hover{color:var(--accent)} +/* Discussions : mêmes lignes que les presets, plus deux actions au survol. + Elles restent visibles au clavier (:focus-within) pour rester atteignables + sans souris. */ +.conv-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s} +.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1} +.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer} +.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)} /* pastille d'état service, en haut à droite de la barre latérale */ .statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer} .statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)} diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go index eccfe07..41c25ee 100644 --- a/internal/loki/web_chat.go +++ b/internal/loki/web_chat.go @@ -120,6 +120,60 @@ func handleChatReset(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true}) } +// --- Discussions (plusieurs fils) ------------------------------------------ +// Le fil actif est PARTAGÉ par tous les appareils, comme la conversation unique +// de l'amont : basculer sur le téléphone bascule aussi le PC (l'epoch fait +// rejouer le nouveau fil à tous les abonnés SSE). + +func handleConvList(w http.ResponseWriter, r *http.Request) { + list, active := ConvList() + if list == nil { + list = []convMeta{} + } + sendJSON(w, 200, map[string]any{"conversations": list, "active": active}) +} + +func handleConvNew(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, map[string]any{"ok": true, "id": convNew()}) +} + +func handleConvSwitch(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := convSwitch(req.ID); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +func handleConvRename(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + Title string `json:"title"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := convRename(req.ID, req.Title); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +func handleConvDelete(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := convDelete(req.ID); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + // handleChatCompact lance une compaction manuelle du contexte (bouton UI). La // progression est diffusée via le flux d'abonnement (compacting/compacted). func handleChatCompact(w http.ResponseWriter, r *http.Request) { diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 5a5e164..8b35c37 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -194,12 +194,18 @@ func newWebMux() *http.ServeMux { api("/api/restart", svcHandler("restart")) api("/api/bench", handleBench) api("/api/bench/last", handleBenchLast) - api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil - api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée) - api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant) - api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only) - api("/api/chat/stop", handleChatStop) // interrompt la génération en cours - api("/api/chat/reset", handleChatReset) // nouvelle conversation (pour tous les appareils) + api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil + api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée) + api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant) + api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only) + api("/api/chat/stop", handleChatStop) // interrompt la génération en cours + api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils) + // Discussions : liste, création, bascule, renommage, suppression. + api("/api/conversations", handleConvList) + api("/api/conversations/new", handleConvNew) + api("/api/conversations/switch", handleConvSwitch) + api("/api/conversations/rename", handleConvRename) + api("/api/conversations/delete", handleConvDelete) api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used} api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)