Files
Loki/internal/ajean/chat_cmd.go
T
nathaninline 993be881d5 Renommage AJEAN (3/3) : textes, aide, README et variables d'environnement
Surface visible passee en AJEAN : aide de la CLI, messages, invite du chat
terminal, commentaires, README, metadonnees Windows du binaire
(InternalName / OriginalFilename).

Les reglages s'appellent desormais AJEAN_* (AJEAN_HOME, AJEAN_MODEL_DIRS,
AJEAN_DL_CONNS, AJEAN_LINK_URL, AJEAN_ACME_EMAIL, AJEAN_SERVICE), avec
l'ancien nom JEAN_* toujours lu en second via envAny(). Ces variables sont
posees a la main dans des shells, des units, des docker-compose et des
scripts de deploiement qu'on ne peut pas retrouver : cesser de les lire
ferait retomber silencieusement un reglage sur sa valeur par defaut.

Migration one-shot du dossier de donnees retentee dans la fenetre ou
l'installateur Windows a arrete toutes les instances, seule fenetre ou le
rename peut aboutir. Le chemin du binaire est recalcule juste apres, sinon
on reinstallerait dans le dossier qu'on vient de deplacer.

Deux choses volontairement NON renommees, parce que les renommer casserait
des utilisateurs sans rien apporter :

- "model": "jean", le nom expose sur l'endpoint compatible OpenAI. Des
  integrations externes le configurent en dur.
- les cles localStorage (jean-theme, jean.sys...). Les renommer
  reinitialiserait theme, preferences d'affichage et prompt systeme dans le
  navigateur de chaque utilisateur.

Le plist launchd recevait /usr/local/bin/jean en dur : le chemin est
desormais injecte, sinon le daemon macOS aurait refuse de demarrer apres le
renommage.

index.html regenere depuis ui/src via go generate.
2026-08-04 14:16:12 +02:00

158 lines
4.6 KiB
Go

package ajean
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 — ajean 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("ajean 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("ajean") + " > ")
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"
}
if inReason {
fmt.Print("\n")
inReason = false
}
fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("ajean")+" >")
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("ajean") + " > ")
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()})
}
}
}