Files
Loki/internal/loki/chat_cmd.go
T
Loki 6fe94b9a8d Captures de pages web (Playwright) affichées dans le fil
Outil web_screenshot : Chromium piloté par Playwright photographie une page
RENDUE (JavaScript exécuté) dans le dossier de travail, et rend au modèle la
ligne markdown exacte à recopier — lui laisser composer l'URL reviendrait à
lui faire inventer un chemin, donc une image cassée.

INDÉPENDANT DE LA VISION, souvent confondu : visionEnabled() (clé MMPROJ) ne
décide que d'une chose, l'envoi d'images AU MODÈLE. Capturer et afficher ne
passent pas par le modèle — sans projecteur, l'agent photographie sans
regarder, ce qui suffit à illustrer une conversation.

- route /api/chat/image : sert UNIQUEMENT des images du dossier de travail,
  en ligne. handleChatFile force le téléchargement de tout pour qu'un .html
  du modèle ne s'exécute pas dans l'origine de l'UI ; ici la même règle est
  tenue autrement — type déduit du CONTENU (pas de l'extension), nosniff, et
  CSP default-src 'none'. Un faux .png contenant du HTML est refusé en 415.
- UI : une balise <img> ne peut pas porter d'en-tête Authorization, or /api/*
  exige la clé dès qu'elle est définie. Les images sont donc récupérées par
  fetch authentifié puis posées en blob:.
- l'outil n'est déclaré au modèle QUE si Playwright est réellement présent :
  annoncer un outil absent envoie le modèle en boucle de réessai.
- Node 22 (NodeSource) au lieu du Node 18 d'Ubuntu, exigé par Playwright et
  par la plupart des serveurs MCP. PLAYWRIGHT=0 bâtit une image sans navigateur.
- CONFIG ACTIVE affichait « llama.cpp personnalisé » pour le moteur de
  l'image : il annonce maintenant « llama.cpp de l'image ».

Description de l'outil tenue au plus court : TestSystemPromptStaysLean a
attrapé le dépassement du budget de préambule (7888 car pour 7500).

Vérifié dans l'image : capture réelle d'une page, servie en image/png avec
CSP ; faux PNG rejeté (415) ; évasion du workspace bloquée (403).
Playwright ajoute 816 Mo à l'image.
2026-08-15 11:54:52 +00:00

161 lines
4.7 KiB
Go

package loki
import (
"bufio"
"context"
"fmt"
"os"
"strings"
)
// cmdChat is the interactive terminal chat loop.
// First positional arg (if any) becomes the system prompt.
func cmdChat(args []string) error {
if !healthCheck() {
return fmt.Errorf("serveur injoignable sur :%d — loki start d'abord", LLMPort())
}
sysPrompt := strings.Join(args, " ")
msgs := []Message{}
if sysPrompt != "" {
msgs = append(msgs, Message{Role: "system", Content: sysPrompt})
}
fmt.Printf("\n%s — /reset pour vider, /sys <prompt> pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("loki chat"))
if sysPrompt != "" {
fmt.Println(dim("system: " + sysPrompt))
}
fmt.Println()
sc := bufio.NewScanner(os.Stdin)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for {
fmt.Print(bold("you") + " > ")
if !sc.Scan() {
fmt.Println()
return nil
}
user := sc.Text()
u := strings.TrimSpace(user)
if u == "" {
continue
}
if u == "/quit" || u == "/exit" {
return nil
}
if u == "/reset" {
kept := []Message{}
for _, m := range msgs {
if m.Role == "system" {
kept = append(kept, m)
}
}
msgs = kept
fmt.Println(dim("[contexte vidé]"))
continue
}
if strings.HasPrefix(u, "/sys ") {
newSys := strings.TrimSpace(u[5:])
msgs = msgs[:0]
if newSys != "" {
msgs = append(msgs, Message{Role: "system", Content: newSys})
}
fmt.Println(dim("[system mis à jour]"))
continue
}
msgs = append(msgs, Message{Role: "user", Content: user})
full := strings.Builder{}
inReason := false
var stats *StatsEvent
// Print the assistant prefix once; reasoning is shown inline with a tag.
fmt.Print(cyan("loki") + " > ")
caps := globalCaps()
// Compaction proactive (façon Hermes) : on résume les vieux tours quand
// l'historique dépasse le seuil, au lieu d'imposer un /reset.
if compacted, changed := MaybeCompact(context.Background(), msgs, caps, 0); changed {
msgs = compacted
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
}
extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
case ev.NewHistory != nil:
// Compaction survenue pendant le tour : elle REMPLACE l'historique (elle
// contient déjà le tour en cours), préfixe système injecté retiré. Sans
// ça le terminal repartirait du fil complet au tour suivant.
base := ev.NewHistory
for len(base) > 0 && base[0].Role == "system" {
base = base[1:]
}
msgs = append([]Message(nil), base...)
fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]"))
case ev.Stats != nil:
stats = ev.Stats
case ev.DropReasoning:
// Le tour a « pensé sans agir » : on relance. Impossible d'effacer le
// texte déjà imprimé en terminal — on referme juste la ligne reasoning.
if inReason {
fmt.Print("\n")
inReason = false
}
case ev.ToolUsed != nil:
if ev.ToolUsed.Done || ev.ToolUsed.Typing {
break // résultat / frappe live affichés côté web ; en terminal on garde l'annonce seule
}
icon := "🧠"
verb := "mémoire"
switch ev.ToolUsed.Name {
case "bash":
icon = "⚙️"
verb = "exécution"
case "write":
icon = "📄"
verb = "écriture"
case "edit":
icon = "✏️"
verb = "édition"
case "web_search", "web_open", "web_read", "web_grep":
icon = "🌐"
verb = "web"
case "web_screenshot":
icon = "📸"
verb = "capture"
}
if inReason {
fmt.Print("\n")
inReason = false
}
fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("loki")+" >")
case ev.Reasoning != "":
if !inReason {
fmt.Print(magenta("[reasoning] ") + dim(""))
inReason = true
}
fmt.Print(dim(ev.Reasoning))
case ev.Content != "":
if inReason {
fmt.Print("\n" + cyan("loki") + " > ")
inReason = false
}
full.WriteString(ev.Content)
fmt.Print(ev.Content)
}
return true
})
if inReason {
fmt.Println()
}
fmt.Println()
if stats != nil {
fmt.Printf("%s prefill %d tok · %.0f tok/s decode %d tok · %.1f tok/s\n",
dim("→"), stats.PromptTokens, stats.PromptPerSecond, stats.GenTokens, stats.GenPerSecond)
}
fmt.Println()
if err == nil {
// Persist the tool turns (skill reads, shell runs) BEFORE the final
// answer, so next turn the model remembers it already did them instead
// of re-invoking the same skill/command from scratch.
msgs = append(msgs, extra...)
msgs = append(msgs, Message{Role: "assistant", Content: full.String()})
}
}
}