mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 11:49:43 +02:00
API interne d'un tour de discussion tenu par Rust
Ce qui décrit un tour reste au Go le temps d'être porté (lot 7) ; la façade le demande ici : - /turn/start : pièces jointes et vision (userMessageContent), capacités demandées et celles du tour (rôle du mode Code figé), nom du modèle, puce « mode Code » (une fois par discussion) ; - /turn/view : la tête de turnView sans PROJ_SNAPSHOT (prompt du preset, consignes du dépôt, contexte du projet), le préambule d'InjectSkills et les outils du tour, MCP compris ; 409 sous PROJ_SNAPSHOT ; - /turn/done : notification Web Push de fin de tour ; - /conv/active : la discussion active des outils suit la façade ; - /conv/dropped : fichiers, travaux, critères, mode et résultats d'outils d'une discussion supprimée. injectPrefix et codeHintDue sortent d'InjectSkills et de maybeCodeHint, sans changement de comportement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
5 files changed
+334
-17
No files matched your search
@@ -86,18 +86,26 @@ func convHasRepo() bool {
|
||||
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
|
||||
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
|
||||
func maybeCodeHint(c *Conversation, epoch int, text string) {
|
||||
if codeHintDue(text) {
|
||||
c.appendDelta(epoch, map[string]any{"code_hint": true})
|
||||
}
|
||||
}
|
||||
|
||||
// codeHintDue dit si la puce doit apparaître pour ce message, et la marque
|
||||
// alors comme montrée pour la discussion active (une fois par discussion).
|
||||
func codeHintDue(text string) bool {
|
||||
if convCodeMode() || !agentEnabled() {
|
||||
return
|
||||
return false
|
||||
}
|
||||
id := convEnsureActive()
|
||||
if getBool(bkChat, hintKey(id)) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if !looksLikeCode(text) && !convHasRepo() {
|
||||
return
|
||||
return false
|
||||
}
|
||||
_ = putBool(bkChat, hintKey(id), true)
|
||||
c.appendDelta(epoch, map[string]any{"code_hint": true})
|
||||
return true
|
||||
}
|
||||
|
||||
// --- API ---------------------------------------------------------------------
|
||||
|
||||
+19
-13
@@ -263,6 +263,24 @@ func globalCaps() Caps {
|
||||
// skills directory (when skills are enabled). Merges with an existing system
|
||||
// message if present. tools = the tools sent on this same turn (see prepareTurn).
|
||||
func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message {
|
||||
prefix := injectPrefix(caps, tools)
|
||||
if prefix == "" {
|
||||
return msgs
|
||||
}
|
||||
// Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le
|
||||
// préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait
|
||||
// plus le sortir du bloc système commun (voir isProjectSystem).
|
||||
if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) {
|
||||
existing, _ := msgs[0].Content.(string)
|
||||
merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
|
||||
return merged
|
||||
}
|
||||
return append([]Message{{Role: "system", Content: prefix}}, msgs...)
|
||||
}
|
||||
|
||||
// injectPrefix : le texte qu'InjectSkills pose en tête — préambule d'agent
|
||||
// décidé, puis briefing machine ; "" s'il n'y a rien à dire.
|
||||
func injectPrefix(caps Caps, tools []Tool) string {
|
||||
var parts []string
|
||||
// Decisive-agent preamble (anti-loop) — only when the model actually has
|
||||
// tools, otherwise it nudges a plain chat model to "call tools" it doesn't
|
||||
@@ -273,19 +291,7 @@ func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message {
|
||||
if mp := machineSystemPrompt(caps); mp != "" {
|
||||
parts = append(parts, mp)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return msgs
|
||||
}
|
||||
prefix := strings.Join(parts, "\n\n")
|
||||
// Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le
|
||||
// préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait
|
||||
// plus le sortir du bloc système commun (voir isProjectSystem).
|
||||
if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) {
|
||||
existing, _ := msgs[0].Content.(string)
|
||||
merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
|
||||
return merged
|
||||
}
|
||||
return append([]Message{{Role: "system", Content: prefix}}, msgs...)
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// prepareTurn calcule UNE fois les outils du tour, puis le préambule à partir
|
||||
|
||||
@@ -85,6 +85,16 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalEngineStop(w, r)
|
||||
case "/internal/v1/recall/archive":
|
||||
handleInternalRecallArchive(w, r)
|
||||
case "/internal/v1/turn/start":
|
||||
handleInternalTurnStart(w, r)
|
||||
case "/internal/v1/turn/view":
|
||||
handleInternalTurnView(w, r)
|
||||
case "/internal/v1/turn/done":
|
||||
handleInternalTurnDone(w, r)
|
||||
case "/internal/v1/conv/active":
|
||||
handleInternalConvActive(w, r)
|
||||
case "/internal/v1/conv/dropped":
|
||||
handleInternalConvDropped(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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/conv/active {"id":…} → 204 (discussion active des outils)
|
||||
// POST /internal/v1/conv/dropped {"id":…} → 204 (fichiers, jobs, critères,
|
||||
// mode, résultats d'outils)
|
||||
|
||||
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 sans
|
||||
// PROJ_SNAPSHOT) 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.
|
||||
func handleInternalTurnView(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Caps Caps `json:"caps"`
|
||||
}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
caps := in.Caps
|
||||
if caps.Agent && projSnapEnabled(ReadConfig()) {
|
||||
sendJSON(w, http.StatusConflict, map[string]any{"error": "PROJ_SNAPSHOT actif : le bloc projet figé reste tenu par le Go"})
|
||||
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})
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.ID) == "" {
|
||||
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"})
|
||||
return
|
||||
}
|
||||
stopConvJobs(in.ID)
|
||||
critDrop(in.ID)
|
||||
dropConvMode(in.ID)
|
||||
dropConvFiles(in.ID)
|
||||
deleteToolResultsFor(in.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func turnJSON(t *testing.T, path, body string, want int) map[string]any {
|
||||
t.Helper()
|
||||
w := appelOAIInterne(t, "POST", path, body)
|
||||
if w.Code != want {
|
||||
t.Fatalf("%s : %d %s", path, w.Code, w.Body.String())
|
||||
}
|
||||
var m map[string]any
|
||||
if w.Body.Len() > 0 {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Début d'un tour : message vu par le modèle, modèle, capacités du tour,
|
||||
// puce « mode Code » une seule fois par discussion.
|
||||
func TestTourInterneDebut(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
turnJSON(t, "/internal/v1/turn/start", `{"message":" "}`, 400)
|
||||
if err := SetConfigKey("MODEL", "/data/models/Qwen3-8B.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := turnJSON(t, "/internal/v1/turn/start", `{"message":"salut"}`, 200)
|
||||
if got["content"] != "salut" || got["model"] != "Qwen3-8B" || got["code_hint"] != false {
|
||||
t.Fatalf("début : %v", got)
|
||||
}
|
||||
if files, _ := got["files"].([]any); files == nil || len(files) != 0 {
|
||||
t.Fatalf("files : %v", got["files"])
|
||||
}
|
||||
if c, _ := got["caps"].(map[string]any); c["Agent"] != false {
|
||||
t.Fatalf("caps sans agent : %v", c)
|
||||
}
|
||||
|
||||
// Agent actif : un message qui ressemble à du code fait apparaître la puce,
|
||||
// une seule fois.
|
||||
if err := setAgentEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = turnJSON(t, "/internal/v1/turn/start", "{\"message\":\"corrige main.go\"}", 200)
|
||||
if got["code_hint"] != true {
|
||||
t.Fatalf("puce attendue : %v", got)
|
||||
}
|
||||
if got = turnJSON(t, "/internal/v1/turn/start", "{\"message\":\"corrige main.go\"}", 200); got["code_hint"] != false {
|
||||
t.Fatalf("puce montrée deux fois : %v", got)
|
||||
}
|
||||
|
||||
// Mode Code : rôle du tour figé (plan demandé → planner).
|
||||
if err := setConvMode(convEnsureActive(), "code"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = turnJSON(t, "/internal/v1/turn/start", `{"message":"fais un plan"}`, 200)
|
||||
tc, _ := got["turn_caps"].(map[string]any)
|
||||
c, _ := got["caps"].(map[string]any)
|
||||
if tc["Code"] != true || tc["Role"] == "" || c["Role"] != "" {
|
||||
t.Fatalf("rôle du tour : caps=%v turn_caps=%v", c, tc)
|
||||
}
|
||||
}
|
||||
|
||||
// Vue d'un tour : rien du projet sans agent ; tête et préambule avec ; refus
|
||||
// sous PROJ_SNAPSHOT.
|
||||
func TestTourInterneVue(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
got := turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200)
|
||||
if h, _ := got["head"].([]any); len(h) != 0 {
|
||||
t.Fatalf("sans agent, rien du projet : %v", got["head"])
|
||||
}
|
||||
off := Caps{Mem: MemOff}
|
||||
if got["prefix"] != injectPrefix(off, EnabledTools(off)) {
|
||||
t.Fatalf("préambule sans agent : %v", got["prefix"])
|
||||
}
|
||||
if err := saveSysPrompt("Réponds en vers."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true,"Mem":"off"}}`, 200)
|
||||
tools, _ := got["tools"].([]any)
|
||||
prefix, _ := got["prefix"].(string)
|
||||
head, _ := got["head"].([]any)
|
||||
if len(tools) == 0 || prefix == "" || len(head) == 0 {
|
||||
t.Fatalf("agent : %d outils, préambule %q, tête %v", len(tools), prefix, head)
|
||||
}
|
||||
if h0, _ := head[0].(map[string]any); h0["content"] != "Réponds en vers." {
|
||||
t.Fatalf("prompt du preset en tête : %v", head[0])
|
||||
}
|
||||
// Même préambule que celui que pose InjectSkills.
|
||||
var caps Caps
|
||||
_ = json.Unmarshal([]byte(`{"Agent":true,"Mem":"off"}`), &caps)
|
||||
if want := injectPrefix(caps, EnabledTools(caps)); prefix != want {
|
||||
t.Fatal("préambule différent de celui d'InjectSkills")
|
||||
}
|
||||
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 409)
|
||||
turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200)
|
||||
}
|
||||
|
||||
// État par discussion : l'active suit la façade ; une discussion supprimée
|
||||
// emporte son mode ; fin de tour sans abonnés push.
|
||||
func TestTourInterneDiscussions(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
turnJSON(t, "/internal/v1/conv/active", `{"id":"c99"}`, 204)
|
||||
if got := convActiveCached(); got != "c99" {
|
||||
t.Fatalf("active en cache : %q", got)
|
||||
}
|
||||
turnJSON(t, "/internal/v1/conv/active", `{"id":" "}`, 400)
|
||||
if err := setConvMode("c99", "code"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
turnJSON(t, "/internal/v1/conv/dropped", `{"id":"c99"}`, 204)
|
||||
if getStr(bkChat, modeKey("c99")) != "" {
|
||||
t.Fatal("mode de la discussion supprimée resté en base")
|
||||
}
|
||||
turnJSON(t, "/internal/v1/turn/done", `{"ms":1200}`, 204)
|
||||
if w := appelOAIInterne(t, "POST", "/internal/v1/conv/dropped", "{"); w.Code != 400 || strings.Count(w.Body.String(), "error") != 1 {
|
||||
t.Fatalf("corps illisible : %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user