mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
StartTurn persistait AVANT de lancer la génération : un marshal, puis deux commits bbolt (contenu, puis index), soit deux fsync et plusieurs ouvertures de base — 26-30 ms mesurés sur un NVMe Windows, bien plus sur un /mnt/user d'Unraid ou un disque de parité, payés à chaque message avant que la requête ne parte vers llama-server. Même facture au milieu d'un tour (vérification du mode Code) et pour chaque résultat d'outil coupé (« voir plus »). - chat_persist.go : un écrivain unique et ordonné. L'instantané reste pris sous c.mu (images → références, marshal, identifiant de la discussion) ; seule l'E/S part en différé. Le dernier instantané de chaque discussion l'emporte (numéro pris sous c.mu), les lots sont fusionnés en UNE transaction : contenu + index ensemble, résultats d'outils compris. - Seuls StartTurn, la persistance en cours de tour et les résultats d'outils sont asynchrones. Fin de tour, reset, bascules, compactage manuel restent synchrones et attendent tout ce qui précède ; la suppression écarte puis attend les écritures qui la visent (plus de discussion ressuscitée). - Discussion active gardée en RAM, créée sous verrou (plus de double identifiant sur base neuve) et changée sous c.mu avec le contenu : un instantané ne peut plus écrire l'ancien fil sous le nouvel identifiant. - « Voir plus » servi depuis la mémoire tant que le résultat n'est pas écrit ; verrouillage vérifié avant de rendre un id. - En plein tour, la base reçoit le journal sous sa forme de fin de tour (compactLog, version pure de compactLogLocked) ; la mémoire n'est pas touchée. - Vidage de l'écrivain avant redémarrage, « Quitter », exec et (dé)chiffrement. Le contexte vu par le modèle ne change pas : il lit c.Messages en mémoire, les résultats complets d'outils et la compaction du journal ne servent qu'à l'UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
11 KiB
Go
348 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) {
|
|
convIndexMu.Lock()
|
|
defer convIndexMu.Unlock()
|
|
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).
|
|
//
|
|
// L'identifiant est gardé en RAM (chat_persist.go) : seule la première lecture
|
|
// ouvre la base. Le test, la lecture et la création se font sous convActiveMu —
|
|
// deux appelants simultanés sur une base neuve forgeaient sinon chacun leur
|
|
// identifiant, et le second rendait orpheline la discussion du premier.
|
|
func convEnsureActive() string {
|
|
if id := convActiveCached(); id != "" {
|
|
return id
|
|
}
|
|
convActiveMu.Lock()
|
|
defer convActiveMu.Unlock()
|
|
if id := convActiveCached(); id != "" {
|
|
return id
|
|
}
|
|
if id := getStr(bkChat, ckActive); id != "" {
|
|
convActiveRemember(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()
|
|
project := activeProjectSlug()
|
|
convIndexMu.Lock()
|
|
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: project}))
|
|
convIndexMu.Unlock()
|
|
convActiveRemember(id)
|
|
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()) }
|
|
|
|
// 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.
|
|
convActivate(list[0].ID, 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 == convEnsureActive() {
|
|
return nil
|
|
}
|
|
conv.persist() // fige la discussion qu'on quitte
|
|
convActivate(id, 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()
|
|
project := activeProjectSlug()
|
|
convIndexMu.Lock()
|
|
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: project}))
|
|
convIndexMu.Unlock()
|
|
convActivate(id, 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")
|
|
}
|
|
convIndexMu.Lock()
|
|
defer convIndexMu.Unlock()
|
|
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()
|
|
convIndexMu.Lock()
|
|
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)
|
|
}
|
|
convIndexMu.Unlock()
|
|
if !found {
|
|
return fmt.Errorf("discussion introuvable")
|
|
}
|
|
// Écritures différées (chat_persist.go) : on écarte d'abord tout ce qui
|
|
// viserait encore cette discussion, puis on attend l'écriture déjà en vol —
|
|
// sinon elle la ferait renaître, contenu et entrée d'index, juste après.
|
|
persistQ.forget(id)
|
|
persistQ.flush()
|
|
convIndexMu.Lock()
|
|
next = next[:0]
|
|
for _, m := range convIndex() {
|
|
if m.ID != id {
|
|
next = append(next, m)
|
|
}
|
|
}
|
|
convIndexSave(next)
|
|
convIndexMu.Unlock()
|
|
_ = 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 != convEnsureActive() {
|
|
return nil
|
|
}
|
|
if len(next) == 0 {
|
|
convCreate() // surtout pas convNew : il réenregistrerait la supprimée
|
|
return nil
|
|
}
|
|
convActivate(next[0].ID, storeBytes(bkChat, convKey(next[0].ID)))
|
|
return nil
|
|
}
|