mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Le rappel de budget d'outils et la relance « pensé sans agir » partaient au moteur sans entrer dans l'historique : au tour suivant, le fil divergeait juste avant eux et toute la boucle d'outils qui suivait était recalculée. Le modèle relisait en prime une histoire qu'il n'avait jamais vue. La relance après un 500 (appel d'outil mal parsé) retirait les outils et modifiait le message système : tout le prompt repartait de zéro, deux fois. - Rappels persistés tels qu'envoyés (appendNudge), sauf derrière un autre message user : éphémères là, pour ne pas casser à chaque tour les gabarits à alternance stricte. - Ils restent reconnaissables (isLokiInjected) : la compaction ne les réinjecte pas comme demande en cours et ne les prend pas pour la preuve qu'une demande a été servie ; la réduction forcée coupe d'abord aux vraies demandes ; le vérificateur, l'export, le titre et le résumeur les sautent ou les présentent comme note du système. - Relance après un 500 sur le llama-server local : mêmes outils, même message 0, tool_choice « none » et la consigne au bout du dernier message, jamais persistée. Nouveau refus, appel tenté malgré tout ou réponse vide : repli une fois sur le chemin historique. Preset externe inchangé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
11 KiB
Go
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" || isLokiInjected(m) {
|
|
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
|
|
}
|