Files
Loki/internal/loki/web_chat.go
T
Claude 0f58ad7b49 Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui
nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à
9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA
l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle
avant l'exécution, attente du rechargement), accès mémoire et web réglables,
interrupteur maître pour tout suspendre, bouton « tester maintenant ».

Le planificateur est une goroutine, un tic par minute, dans le process qui
détient la conversation et parle au moteur. Une seule tâche part par tic : de
toute façon une seule inférence tourne à la fois, et étaler les départs évite
qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement
n'est pas un échec — la tâche repasse au tic suivant.

Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni
messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et
le jette, ne gardant que son texte final comme compte-rendu (borné à 4000
caractères, réinjecté au passage suivant pour la continuité).

Deux adaptations, parce que loki n'est pas l'amont :

— Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y
  aurait déposé ses fichiers, et en aurait changé en cours de route si
  l'utilisateur changeait de discussion — pour disparaître avec elle à la
  suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un
  passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS
  (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages
  continuent de suivre la discussion de l'utilisateur.

— Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le
  zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme
  « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le
  mode code reste off : rôles, critères et passe de vérification n'ont pas de
  sens sans personne en face.

Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe
le modèle : « génération en cours » sur un fil vide et immobile n'expliquait
rien.

Interface : section repliable dans les réglages (liste, état, prochain passage,
pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau
« dernier résultat » en markdown. Vérifié dans un vrai navigateur — création,
rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître,
suppression — sans une seule erreur JS.
2026-08-20 09:31:59 +00:00

231 lines
7.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) {
conv.Subscribe(ctx, body.From, 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())
}
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)
}