mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Repris d'AJEAN 0.15.7.
HISTORIQUE PAGINÉ
Rouvrir une longue conversation d'agent rejouait TOUT le journal : des Mo
à travers le tunnel, et des milliers de bulles construites avant
d'afficher la moindre chose. Le client annonce désormais `tail` : au
chargement (et à l'ouverture d'une autre discussion), le serveur ne rejoue
que les 20 derniers échanges, précédés de {history_more: N}. Un bouton en
tête du fil redemande le rejeu complet en gardant la position de lecture.
Un client qui n'envoie pas `tail` (app relais…) reçoit tout, comme avant.
Écart avec l'amont : les états que le client garde et que la partie
masquée avait posés — mode Chat/Code, critères du mode Code — sont réémis
à la coupe (historyHead), ainsi que ctx_used à l'ouverture d'une
discussion. Sans ça, une discussion en mode Code rouverte s'affichait en
mode Chat, et la jauge de contexte à zéro.
iOS : LES TAPS PERDUS PENDANT UNE RÉPONSE
iOS annule un appui si la position de défilement change pendant qu'il a
lieu. Le fil se recalant en bas à chaque token, presque chaque tap tombait
dessus — y compris sur stop. Le suivi est suspendu pendant un contact (et
350 ms après), scrollTop n'est plus réécrit quand on est déjà en bas, et
stop agit dès l'appui sur écran tactile (anti-doublon pour le clic qui
suit).
Vérifié dans le navigateur sur 23 échanges : 20 affichés + « afficher les
3 échanges précédents », puis rejeu complet à position de lecture égale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
8.9 KiB
Go
264 lines
8.9 KiB
Go
// web_chat.go — endpoints de chat du serveur web local : envoi/stop/reset,
|
|
// flux SSE d'abonnement à la conversation serveur (voir chat_conversation.go).
|
|
package loki
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// capsFromBody dérive les capacités d'un tour à partir de la configuration
|
|
// machine et des surcharges portées par la requête.
|
|
//
|
|
// ⚠️ Une surcharge ne peut que RESTREINDRE. Elle pouvait auparavant rallumer le
|
|
// mode agent : un simple {"agent":true} redonnait bash, write, edit et les
|
|
// outils MCP alors que l'interrupteur de la machine était sur OFF. Comme l'API
|
|
// n'est pas protégée par défaut et écoute sur 0.0.0.0, l'interrupteur ne
|
|
// garantissait donc rien. Il redevient une vraie fermeture : ce qui est éteint
|
|
// sur la machine ne peut pas être rallumé par un client.
|
|
func capsFromBody(body chatReq) Caps {
|
|
caps := globalCaps()
|
|
switch {
|
|
case body.Agent != nil:
|
|
caps.Agent = caps.Agent && *body.Agent
|
|
case body.Tools != nil || body.Skills != nil:
|
|
want := (body.Tools != nil && *body.Tools) || (body.Skills != nil && *body.Skills)
|
|
caps.Agent = caps.Agent && want
|
|
}
|
|
if body.Internet != nil {
|
|
caps.Internet = caps.Internet && *body.Internet
|
|
}
|
|
// Les outils dépendent du mode agent : agent coupé, tout est coupé.
|
|
if !caps.Agent {
|
|
caps.Internet = false
|
|
}
|
|
// Mode code : réglage PAR DISCUSSION (sélecteur Chat/Code du composeur),
|
|
// pas une surcharge de requête — même logique de fermeture que le reste :
|
|
// sans agent, pas de mode code.
|
|
caps.Code = caps.Agent && convCodeMode()
|
|
return caps
|
|
}
|
|
|
|
// sseHeartbeat garde la réponse SSE active en écrivant un commentaire (`: ping`,
|
|
// ignoré par le parseur côté navigateur, aucun contenu donc rien à chiffrer)
|
|
// toutes les ~15 s. Sans ça, un long silence (exécution d'outil en mode agent,
|
|
// gros prefill) laisse la réponse inactive et un proxy intermédiaire (Cloudflare,
|
|
// ~100 s) la coupe → le fetch navigateur échoue (« Load failed »). Retourne un
|
|
// mutex à partager avec l'émetteur (writes concurrents sur le même w) et une
|
|
// fonction d'arrêt à différer.
|
|
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
|
|
mu := &sync.Mutex{}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
// 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester
|
|
// coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser.
|
|
t := time.NewTicker(4 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-t.C:
|
|
mu.Lock()
|
|
_, err := w.Write([]byte(": ping\n\n"))
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
mu.Unlock()
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return mu, func() { close(done) }
|
|
}
|
|
|
|
// runChatStream est désormais un pur ABONNÉ au journal de la conversation serveur :
|
|
// il rejoue Log[body.From:] puis suit le direct, jusqu'à ce que la connexion (ctx)
|
|
// se ferme. La GÉNÉRATION est lancée séparément par /api/chat/send dans une
|
|
// goroutine détachée — fermer le navigateur n'arrête donc plus rien. Partagé par
|
|
// handleChat (clair) et handleE2EChat (chiffré).
|
|
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
|
|
tail := -1
|
|
if body.Tail != nil {
|
|
tail = *body.Tail
|
|
}
|
|
conv.SubscribeTail(ctx, body.From, tail, emit)
|
|
}
|
|
|
|
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse
|
|
// req/resp (les événements arrivent par le flux d'abonnement). Passe par le proxy
|
|
// tunnel /api/e2e/req pour app.ajean.link — aucun code E2E spécifique requis.
|
|
func handleChatSend(w http.ResponseWriter, r *http.Request) {
|
|
var body chatReq
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
// Un envoi SANS texte mais AVEC pièce jointe est légitime (« tiens, regarde »).
|
|
files := attachFiles(body.Files)
|
|
if strings.TrimSpace(body.Message) == "" && len(files) == 0 {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"})
|
|
return
|
|
}
|
|
if err := conv.StartTurn(body.Message, files, capsFromBody(body), body.Temperature); err != nil {
|
|
// 409 = occupé (génération en cours) ; 503 = modèle pas prêt.
|
|
code := 503
|
|
if err == ErrBusy {
|
|
code = 409
|
|
err = conv.busyReason() // dit LAQUELLE des deux occupe le modèle
|
|
}
|
|
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func handleChatStop(w http.ResponseWriter, r *http.Request) {
|
|
conv.Stop()
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func handleChatReset(w http.ResponseWriter, r *http.Request) {
|
|
conv.Reset()
|
|
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{}
|
|
}
|
|
// `busy` = un tour est en cours SUR LA DISCUSSION ACTIVE (il n'y a qu'une
|
|
// génération à la fois, sur le fil ouvert). L'UI s'en sert pour animer la
|
|
// ligne concernée dès le chargement, avant même que le flux SSE ait rejoué
|
|
// le journal — sinon la page reste muette pendant qu'un agent travaille.
|
|
sendJSON(w, 200, map[string]any{"conversations": list, "active": active, "busy": conv.isGenerating()})
|
|
}
|
|
|
|
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) {
|
|
if err := conv.CompactNow(); err != nil {
|
|
code := 503
|
|
if err == ErrBusy {
|
|
code = 409
|
|
err = conv.busyReason()
|
|
}
|
|
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func handleChatState(w http.ResponseWriter, r *http.Request) {
|
|
sendJSON(w, 200, conv.state())
|
|
}
|
|
|
|
// handleToolResult renvoie le résultat COMPLET d'un appel d'outil, chargé à la
|
|
// demande par le bouton « voir plus » de la bulle (le flux ne transporte qu'un
|
|
// aperçu). Le résultat complet vit dans la vue « modèle » (conv.Messages), repéré
|
|
// par son tool_call_id. Absent (compacté, archivé, discussion changée) → 404, et
|
|
// l'UI garde son aperçu plutôt que d'afficher une erreur.
|
|
func handleToolResult(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
if id == "" {
|
|
sendJSON(w, 400, map[string]any{"error": "id manquant"})
|
|
return
|
|
}
|
|
conv.mu.Lock()
|
|
found, ok := "", false
|
|
for _, m := range conv.Messages {
|
|
if m.Role == "tool" && m.ToolCallID == id {
|
|
if s, isStr := m.Content.(string); isStr {
|
|
found, ok = s, true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
conv.mu.Unlock()
|
|
if !ok {
|
|
sendJSON(w, 404, map[string]any{"error": "résultat non disponible"})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"result": found})
|
|
}
|
|
|
|
func handleChat(w http.ResponseWriter, r *http.Request) {
|
|
var body chatReq
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
flusher, _ := w.(http.Flusher)
|
|
mu, stop := sseHeartbeat(w, flusher)
|
|
defer stop()
|
|
emit := func(obj map[string]any) bool {
|
|
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
|
|
return false
|
|
}
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
return true
|
|
}
|
|
runChatStream(r.Context(), body, emit)
|
|
}
|