Files
Loki/internal/loki/chat_sessions.go
T
MichaelandClaude Opus 5.5 ae0e181711 Outils : aperçu dans le flux, « voir plus » à la demande, coupes annoncées
Repris d'AJEAN 0.15.1 → 0.15.7 (tool_results.go adapté : pas de
chiffrement chez nous).

VOIR PLUS
Chaque résultat d'outil partait en entier dans le flux (jusqu'à 12 000
caractères) et dans le journal rejoué à chaque ouverture : un long fil
d'agent en transportait des centaines. Le flux ne porte plus qu'un aperçu
de 1 600 caractères, la taille réelle (le « ~N tok » de la bulle reste
juste) et un id. Le résultat complet est rangé dans le bucket `toolres`,
sous « <discussion>.<aléa> » — un id propre, pas le tool_call_id que
certains parseurs recyclent. « voir plus » le charge au clic via
/api/chat/tool-result et lève le plafond de hauteur du bloc ; il se range
avec « copier » dans une barre au coin du résultat. Les résultats partent
avec leur discussion (convDelete) ; les orphelins sont nettoyés toutes les
200 écritures.

COUPES ANNONCÉES
- Terminal : une sortie de plus de 8 000 caractères était coupée en
  silence ; le modèle croyait tout voir. Elle commence désormais par
  « [sortie tronquée : N caractères au total, seuls les 8000 derniers sont
  gardés] ».
- MCP : l'amont transmet désormais la réponse en entier. On s'en approche
  sans renoncer au garde-fou (65k de contexte) : le plafond suit la
  fenêtre (CTX caractères, ≈ un quart de la fenêtre, jamais sous les
  12 000 d'avant), et la coupe dit la taille réelle.

Vérifié dans le navigateur avec un faux moteur qui appelle bash : aperçu
de 1 600 caractères, « voir plus » charge les 8 099, mention de coupe en
tête.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:08:46 +02:00

333 lines
11 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)
//
// Les FICHIERS suivent le même découpage, sur le disque cette fois :
// <workspace>/discussions/<id>/ (chat_convfiles.go). Supprimer une discussion
// supprime donc aussi ses fichiers.
//
// 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"
"sync"
"time"
)
const (
ckIndex = "index"
ckActive = "active"
ckLegacy = "conversation" // fil unique d'avant le multi-discussions
)
// convOpMu sérialise les opérations de haut niveau sur les discussions
// (création, bascule, renommage, suppression). Chacune fait plusieurs
// lectures-écritures de l'index et de la clé active : deux requêtes HTTP
// simultanées (deux appareils, double-clic) pouvaient entrelacer ces étapes et
// perdre une entrée d'index ou basculer sur une discussion supprimée. Le verrou
// de Conversation protège l'état en mémoire, pas cette séquence-là.
var convOpMu sync.Mutex
// 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"`
// Project = slug du projet auquel la discussion appartient (projects.go). Vide
// sur les discussions d'avant les projets : ensureDefaultProject les rattache à
// « Générale » au premier démarrage, et convIndexForProject traite un champ vide
// comme le projet par défaut pour que rien ne disparaisse entre-temps.
Project string `json:"project,omitempty"`
}
func convKey(id string) string { return "conv:" + id }
// convProjectOf renvoie le projet d'une discussion, en traitant l'absence comme
// le projet par défaut : une discussion sans projet ne doit jamais devenir
// invisible.
func convProjectOf(m convMeta) string {
if s := strings.TrimSpace(m.Project); s != "" {
return s
}
return defaultProjectSlug
}
// convIndexForProject filtre l'index sur un projet.
func convIndexForProject(slug string) []convMeta {
var out []convMeta
for _, m := range convIndex() {
if convProjectOf(m) == slug {
out = append(out, m)
}
}
return out
}
// tagOrphanConversations rattache au projet donné les discussions qui n'en ont
// pas encore (migration, une seule fois : ensuite il n'y a plus d'orphelines).
func tagOrphanConversations(slug string) {
idx := convIndex()
changed := false
for i := range idx {
if strings.TrimSpace(idx[i].Project) == "" {
idx[i].Project = slug
changed = true
}
}
if changed {
convIndexSave(idx)
}
}
func convIndex() []convMeta {
var idx []convMeta
// L'index porte les TITRES des discussions : chiffré comme leur contenu
// quand la mémoire l'est. Verrouillé, il se lit vide — la liste est masquée,
// rien n'est perdu.
if b, ok := getStoreBytes(bkChat, ckIndex); ok && 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
}
_ = putStoreBytes(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.
_ = putStoreBytes(bkChat, convKey(id), legacy)
}
_ = putStr(bkChat, ckActive, id)
now := time.Now().Unix()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
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, Project: activeProjectSlug()}))
}
// 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 DU PROJET ACTIF et l'identifiant de l'active
// (pour l'UI). Le cloisonnement est ici : une discussion d'un autre projet n'a
// aucune raison d'apparaître dans la barre latérale.
func ConvList() ([]convMeta, string) {
return convIndexForProject(activeProjectSlug()), convEnsureActive()
}
// projectSwitch bascule sur un autre projet ET ouvre une de ses discussions : la
// plus récemment modifiée, ou une neuve s'il n'en a aucune. Sans ça, changer de
// projet laisserait à l'écran une discussion qui n'appartient plus à la liste
// affichée — et la mémoire (memoryDir) aurait changé sous ses pieds.
func projectSwitch(slug string) error {
if !projectExists(slug) {
return fmt.Errorf("projet introuvable")
}
convOpMu.Lock()
defer convOpMu.Unlock()
if slug == activeProjectSlug() {
return nil
}
conv.persist() // fige la discussion du projet qu'on quitte
if err := setActiveProject(slug); err != nil {
return err
}
list := convIndexForProject(slug)
if len(list) == 0 {
convCreate() // convIndex trie par date de modification : voir convIndex
return nil
}
// convIndex rend la plus récemment modifiée en tête.
_ = putStr(bkChat, ckActive, list[0].ID)
conv.loadFrom(storeBytes(bkChat, convKey(list[0].ID)))
return nil
}
// 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 {
convOpMu.Lock()
defer convOpMu.Unlock()
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(storeBytes(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, Project: activeProjectSlug()}))
_ = 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 {
convOpMu.Lock()
defer convOpMu.Unlock()
conv.persist()
return convCreate()
}
func convRename(id, title string) error {
convOpMu.Lock()
defer convOpMu.Unlock()
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 {
convOpMu.Lock()
defer convOpMu.Unlock()
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)
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
// elle, et ses critères/mode/puce partent avec ses messages.
stopConvJobs(id)
critDrop(id)
dropConvMode(id)
// Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
dropConvFiles(id)
deleteToolResultsFor(id) // ses résultats « voir plus » partent avec elle
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(storeBytes(bkChat, convKey(next[0].ID)))
return nil
}