Files
Loki/internal/loki/web_internal_turn.go
T
Claude ad52da18a9 Lot 34 : bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
PROJ_SNAPSHOT était le dernier réglage qui gardait la conversation au
Go : la façade la tient désormais dans tous les cas (LOKI_CHAT=go reste
la porte de sortie). facadeChatBlocker et la décision prise une fois par
processus disparaissent.

Go (facade_projsnap.go) :
- /internal/v1/turn/view, avec la clé et l'agent : tête du tour portant
  le bloc figé (ou le vivant à rafraîchir), préambule sans date ni
  dossier, et la décision projsnap (rafraîchir, mise à jour à poser,
  instantané à ranger, clé et système du tour) ; dry pour le
  préchauffage ;
- /internal/v1/turn/projsnap/refresh : bloc vivant et instantané qu'il
  devient, pour une compaction en plein tour.

Rust :
- ChatService::assemble_turn, partagé par le tour et la relance du
  builder : instantané rangé avec la discussion, mise à jour posée en tête
  du dernier message utilisateur (prependCtxUpdate), historique nettoyé
  au rafraîchissement ;
- TurnHost::project_refresh après une compaction ou une réduction en
  plein tour (replaceProjectHead) ; l'instantané posé devient celui de la
  discussion ;
- le préchauffage demande la vue en dry et garde les mises à jour tant
  que le bloc n'est pas rafraîchi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:13:27 +00:00

222 lines
7.4 KiB
Go

package loki
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
)
// API interne d'un tour de discussion tenu par la façade Rust (lot 6).
//
// La boucle, le journal et la persistance sont en Rust ; ce qui décrit le
// tour reste ici, le temps d'être porté (lot 7) : pièces jointes et vision,
// rôle du mode Code, prompt du preset, consignes du dépôt, contexte du
// projet, préambule d'agent, outils (MCP compris), puce « mode Code »,
// notification Web Push, et l'état par discussion que les outils lisent.
//
// POST /internal/v1/turn/start corps de /api/chat/send
// → {files, caps, turn_caps, content, model, code_hint}
// POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools}
// 409 : PROJ_SNAPSHOT actif (le bloc projet figé reste au Go)
// POST /internal/v1/turn/done {"ms":N} → 204 (Web Push si abonnés)
// POST /internal/v1/project/activate {"slug":…} → 204 (projet actif changé
// par la façade, qui a enregistré la discussion du projet quitté)
// POST /internal/v1/turn/verifier {"caps":{…}} → {system, tools, caps}
// (passe de vérification du mode Code : contexte ISOLÉ, rôle verifier)
// POST /internal/v1/conv/active {"id":…} → 204 (discussion active des outils)
// POST /internal/v1/conv/dropped {"id":…} → 204 (fichiers, jobs, critères,
// mode, résultats d'outils ;
// "files_only" : les fichiers)
const internalTurnMaxBody = 8 << 20
func readInternalJSON(w http.ResponseWriter, r *http.Request, v any) bool {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
return false
}
raw, err := io.ReadAll(io.LimitReader(r.Body, internalTurnMaxBody+1))
if err != nil || len(raw) > internalTurnMaxBody || json.Unmarshal(raw, v) != nil {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "corps illisible"})
return false
}
return true
}
// handleInternalTurnStart : ce que StartTurn et EnqueueOrStart tirent de la
// requête avant de lancer (ou de mettre en file) le tour. caps : capacités
// demandées (file d'attente, préchauffage) ; turn_caps : celles du tour, rôle
// du mode Code figé ; content : le message utilisateur vu par le modèle.
func handleInternalTurnStart(w http.ResponseWriter, r *http.Request) {
var body chatReq
if !readInternalJSON(w, r, &body) {
return
}
files := attachFiles(body.Files)
if strings.TrimSpace(body.Message) == "" && len(files) == 0 {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "message vide"})
return
}
rememberUserTZ(body.TZ)
caps := capsFromBody(body)
turnCaps := withTurnRole(caps, body.Message)
prompt := body.Message
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
if files == nil {
files = []attachInfo{}
}
sendJSON(w, http.StatusOK, map[string]any{
"files": files,
"caps": caps,
"turn_caps": turnCaps,
"content": userMessageContent(files, prompt),
"model": chatModelName(),
"code_hint": !turnCaps.Code && codeHintDue(body.Message),
})
}
// handleInternalTurnView : les pièces de turnView (assembleTurn) que la
// façade assemble autour de l'historique : head part devant les messages, puis
// prefix (InjectSkills) fusionné dans le premier système qui n'est pas un
// message projet, ou posé en tête. Avec PROJ_SNAPSHOT et l'agent, la tête
// porte le bloc figé de la discussion (snap) et projsnap dit quoi en faire
// (facade_projsnap.go) ; dry : pour le préchauffage, rien à ranger.
func handleInternalTurnView(w http.ResponseWriter, r *http.Request) {
var in struct {
Caps Caps `json:"caps"`
Snap *projSnapshot `json:"snap"`
Dry bool `json:"dry"`
}
if !readInternalJSON(w, r, &in) {
return
}
caps := in.Caps
if caps.Agent && projSnapEnabled(ReadConfig()) {
sendJSON(w, http.StatusOK, projSnapView(caps, in.Snap, in.Dry))
return
}
head := []Message{}
if caps.Agent {
if sp := readSysPrompt(); sp != "" {
head = append(head, Message{Role: "system", Content: sp})
}
if m, ok := codeInstructionsMessage(caps); ok {
head = append(head, m)
}
head = append(head, projectSystemMessages()...)
}
tools := EnabledTools(caps)
if tools == nil {
tools = []Tool{}
}
sendJSON(w, http.StatusOK, map[string]any{"head": head, "prefix": injectPrefix(caps, tools), "tools": tools})
}
// handleInternalTurnVerifier : ce que runVerifyPass envoie au moteur hors de
// l'historique — le prompt du rôle verifier suivi des faits de la machine, et
// le jeu d'outils fermé de la vérification. caps : celles du tour, rôle
// remplacé (le Go s'y fie pour laisser l'outil criteria marquer « passed »).
func handleInternalTurnVerifier(w http.ResponseWriter, r *http.Request) {
var in struct {
Caps Caps `json:"caps"`
}
if !readInternalJSON(w, r, &in) {
return
}
vcaps := in.Caps
vcaps.Role = "verifier"
sendJSON(w, http.StatusOK, map[string]any{
"system": rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps),
"tools": EnabledTools(vcaps),
"caps": vcaps,
})
}
// handleInternalProjectActivate : setActiveProject pour la façade (projet
// rangé en base, cache de ceux qui le lisent ici — mémoire, trackers, outils).
func handleInternalProjectActivate(w http.ResponseWriter, r *http.Request) {
var in struct {
Slug string `json:"slug"`
}
if !readInternalJSON(w, r, &in) {
return
}
if !projectExists(in.Slug) {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "projet introuvable"})
return
}
if err := setActiveProject(in.Slug); err != nil {
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func handleInternalTurnDone(w http.ResponseWriter, r *http.Request) {
var in struct {
Ms int64 `json:"ms"`
}
if !readInternalJSON(w, r, &in) {
return
}
if hasPushSubs() {
d := time.Duration(in.Ms) * time.Millisecond
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(d))
}
w.WriteHeader(http.StatusNoContent)
}
// handleInternalConvActive : la façade a changé de discussion active. Les
// outils exécutés ici (dossier de la discussion, mode Code, critères) lisent
// l'identifiant en cache : il suit.
func handleInternalConvActive(w http.ResponseWriter, r *http.Request) {
var in struct {
ID string `json:"id"`
}
if !readInternalJSON(w, r, &in) {
return
}
if strings.TrimSpace(in.ID) == "" {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"})
return
}
convActiveMu.Lock()
convActiveRemember(in.ID)
convActiveMu.Unlock()
w.WriteHeader(http.StatusNoContent)
}
// handleInternalConvDropped : ce qui part avec une discussion supprimée par
// la façade (mêmes effets que la fin de convDelete).
func handleInternalConvDropped(w http.ResponseWriter, r *http.Request) {
var in struct {
ID string `json:"id"`
// FilesOnly : seulement les fichiers (Reset vide la discussion, il ne
// la supprime pas).
FilesOnly bool `json:"files_only"`
}
if !readInternalJSON(w, r, &in) {
return
}
if strings.TrimSpace(in.ID) == "" {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"})
return
}
if in.FilesOnly {
dropConvFiles(in.ID)
w.WriteHeader(http.StatusNoContent)
return
}
stopConvJobs(in.ID)
critDrop(in.ID)
dropConvMode(in.ID)
dropConvFiles(in.ID)
deleteToolResultsFor(in.ID)
w.WriteHeader(http.StatusNoContent)
}