mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Trois causes d'encombrement traitées : - JPEG au lieu de PNG (Playwright déduit le format de l'extension). Sur une vraie page web — photos, dégradés — le JPEG pèse 3 à 10 fois moins ; le PNG ne gagnait que sur les aplats, cas minoritaire ici. - Pleine page DÉSACTIVÉE par défaut : un article long capturé en entier fait plusieurs milliers de pixels de haut, donc plusieurs Mo, alors que « montre cette page » veut presque toujours dire le premier écran. Le modèle peut toujours demander full_page. - Ménage automatique : au plus 20 captures et 40 Mo par discussion, les plus anciennes partant en premier. La capture qui vient d'être prise n'est JAMAIS supprimée — une capture plus lourde que le plafond se serait effacée elle-même, et le modèle aurait renvoyé un lien mort. Les captures sont désormais rangées PAR DISCUSSION (captures/<id>/…) : supprimer une discussion, ou la vider, emporte ses images. Sans ce rangement, des fichiers que plus aucun message n'affiche restaient sur le disque. Tests : ménage (nombre et octets), survie de la capture courante, et suppression qui n'emporte que les captures de la discussion visée.
228 lines
6.8 KiB
Go
228 lines
6.8 KiB
Go
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:<id> → 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)
|
|
// Les captures de cette discussion n'ont plus rien qui les référence : les
|
|
// garder occuperait le disque pour toujours.
|
|
dropConvCaptures(id)
|
|
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
|
|
}
|