v0.6.10 : dossier de travail pour l'agent, lancement Windows explicite, mise à jour lisible, icône noire

- mode agent : les outils write/edit/bash résolvent les chemins relatifs dans
  JEAN_HOME/workspace et le shell y démarre, au lieu du répertoire courant du
  processus (Bureau, ProgramData\jean\bin). Les chemins absolus demandés
  explicitement restent honorés.
- Windows : le double-clic n'installe plus en silence. Boîte de dialogue unique,
  raccourcis menu Démarrer + Bureau, puis relance depuis la copie installée — un
  seul binaire fait foi. Désinstallation : retrait des raccourcis.
- update : contrôle des droits AVANT le téléchargement (l'échec tombait sur le
  fichier temporaire, avec un message brut), message donnant la commande exacte,
  timeout UI porté de 30 s à 10 min.
- jean where + bouton « Où sont mes fichiers ? » : emplacements, et alerte quand
  le binaire lancé n'est pas celui installé.
- icônes : source unique (sys_brand_icon.go) pour la zone de notification, la
  barre de menus macOS et l'icône du .exe, alignée sur le favicon noir. macOS en
  icône template (une icône noire figée disparaît sur une barre sombre).
- CI : le bundle macOS part de icon.png au lieu de convertir le .ico via sips,
  dont l'échec était masqué par « || true » ; notes de release versionnées.
This commit is contained in:
nathaninline committed 2026-08-04 11:17:43 +02:00
1 parent 6e7fe7d4a6
commit 1f3961eaa9
28 files changed
+742 -130

No files matched your search

+9 -1
View File
@@ -114,7 +114,7 @@ func machineSystemPrompt(caps Caps) string {
if u, err := user.Current(); err == nil {
who = u.Username
}
cwd, _ := os.Getwd()
cwd := agentWorkspace()
var b strings.Builder
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
@@ -125,6 +125,9 @@ func machineSystemPrompt(caps Caps) string {
b.WriteString(", cwd=" + cwd)
}
b.WriteString(".")
if cwd != "" {
b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder jean was launched from.")
}
return b.String()
}
@@ -142,6 +145,9 @@ func runShell(command string, timeoutSec int) string {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
// Le shell démarre dans le workspace, pas dans le dossier d'où jean a été
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
cmd.Dir = agentWorkspace()
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
@@ -188,6 +194,7 @@ func fileWrite(path, content string) string {
if strings.TrimSpace(path) == "" {
return "[erreur] chemin vide"
}
path = resolveAgentPath(path)
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "[erreur] " + err.Error()
@@ -221,6 +228,7 @@ func fileEdit(path, oldText, newText string) string {
if oldText == "" {
return "[erreur] old vide"
}
path = resolveAgentPath(path)
b, err := os.ReadFile(path)
if err != nil {
return "[erreur] " + err.Error()
+87
View File
@@ -0,0 +1,87 @@
package jean
import (
"os"
"path/filepath"
"strings"
"sync"
)
// Dossier de travail du mode agent.
//
// Sans ça, les outils write/edit/bash héritent du répertoire courant du PROCESSUS :
// le modèle écrit "meteo.json", et le fichier atterrit là d'où l'utilisateur a
// lancé jean — le Bureau quand on double-clique l'installateur, C:\ProgramData\jean\bin
// quand on lance le binaire installé. Personne ne s'attend à voir un chat déposer
// des fichiers sur son Bureau. On donne donc à l'agent un dossier à lui : les
// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS
// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
// dossier précis, ça doit marcher.
const workspaceEnv = "JEAN_WORKSPACE"
var (
workspaceOnce sync.Once
workspacePath string
)
// agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il
// essaie plusieurs emplacements car JeanHome() vaut %ProgramData%\jean sous
// Windows, qui n'est pas inscriptible par un utilisateur non administrateur.
func agentWorkspace() string {
workspaceOnce.Do(func() {
for _, dir := range workspaceCandidates() {
if dir == "" {
continue
}
if err := os.MkdirAll(dir, 0o755); err != nil {
continue
}
// MkdirAll réussit sur un dossier existant même non inscriptible :
// on vérifie l'écriture réelle.
f, err := os.CreateTemp(dir, ".probe-*")
if err != nil {
continue
}
name := f.Name()
f.Close()
os.Remove(name)
workspacePath = dir
return
}
// Dernier recours : le répertoire courant, comportement historique.
workspacePath, _ = os.Getwd()
})
return workspacePath
}
func workspaceCandidates() []string {
var c []string
if v := strings.TrimSpace(os.Getenv(workspaceEnv)); v != "" {
c = append(c, v)
}
c = append(c, filepath.Join(JeanHome(), "workspace"))
if home, err := os.UserHomeDir(); err == nil && home != "" {
c = append(c, filepath.Join(home, "jean", "workspace"))
}
c = append(c, filepath.Join(os.TempDir(), "jean-workspace"))
return c
}
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
// "~/x" → dans le home de l'utilisateur ; relatif → dans le workspace.
func resolveAgentPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return p
}
if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, filepath.FromSlash(strings.TrimPrefix(p[1:], "/")))
}
}
if filepath.IsAbs(p) {
return p
}
return filepath.Join(agentWorkspace(), filepath.FromSlash(p))
}
+35
View File
@@ -0,0 +1,35 @@
package jean
import (
"path/filepath"
"strings"
"testing"
)
// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le
// répertoire courant du processus (Bureau, C:\ProgramData\jean\bin…).
func TestResolveAgentPathRelative(t *testing.T) {
got := resolveAgentPath("meteo.json")
want := filepath.Join(agentWorkspace(), "meteo.json")
if got != want {
t.Fatalf("resolveAgentPath = %q, attendu %q", got, want)
}
}
// Un chemin absolu demandé explicitement reste intouché.
func TestResolveAgentPathAbsolute(t *testing.T) {
abs := filepath.Join(t.TempDir(), "rapport.md")
if got := resolveAgentPath(abs); got != abs {
t.Fatalf("resolveAgentPath = %q, attendu %q", got, abs)
}
}
func TestResolveAgentPathSubdir(t *testing.T) {
got := resolveAgentPath("notes/2026/a.txt")
if !strings.HasPrefix(got, agentWorkspace()) {
t.Fatalf("%q hors du workspace %q", got, agentWorkspace())
}
if strings.Contains(got, "/") && filepath.Separator != '/' {
t.Fatalf("séparateurs non normalisés : %q", got)
}
}
+5 -4
View File
@@ -12,7 +12,6 @@ import (
"fmt"
"net"
"net/http"
"os"
"time"
)
@@ -25,9 +24,11 @@ const appPort = 8090
func cmdApp(args []string) error {
url := fmt.Sprintf("http://localhost:%d", appPort)
if _, err := os.Stat(confPath()); err != nil {
// Première fois : installe le minimum (dossiers + config de départ).
_ = cmdInstall(nil)
// Premier lancement : prépare le dossier de données et, sous Windows, propose
// explicitement l'installation (voir sys_firstrun_windows.go). Si l'app a été
// relancée depuis la copie installée, ce process n'a plus rien à faire.
if appFirstRun() {
return nil
}
addr := fmt.Sprintf("0.0.0.0:%d", appPort)
+4 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.6.9"
const Version = "0.6.10"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() {
@@ -89,6 +89,8 @@ func Main() {
mustExit(cmdLlamacpp(args))
case "update", "upgrade", "self-update":
mustExit(cmdUpdate(args))
case "where", "paths":
mustExit(cmdWhere(args))
case "install":
mustExit(cmdInstall(args))
case "uninstall":
@@ -153,6 +155,7 @@ Entrypoint (utilisé par jean.service) :
serve lit config.env et exec le binaire llama-server
Installation:
where affiche où sont le binaire, la config, la mémoire et le dossier de travail de l'agent
install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config)
uninstall désinstaller
update [--check] mettre à jour jean depuis les releases GitHub (--check = signale sans installer)
+129
View File
@@ -0,0 +1,129 @@
package jean
// sys_brand_icon.go — l'icône de la marque AJEAN, rendue à la volée.
//
// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis NOIR + « j »
// blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). Une seule source pour
// tous les usages — zone de notification Windows, barre de menus macOS, icône du
// .exe — pour qu'ils ne puissent plus diverger comme quand le favicon est passé
// au noir en laissant les icônes système en bleu.
//
// Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est
// produite par `go generate ./cmd/jean` (voir tools/gen-icon).
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
)
const trayIconSize = 32
var (
brandBlack = color.RGBA{0x00, 0x00, 0x00, 0xff}
brandWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
brandClear = color.RGBA{0, 0, 0, 0}
)
// glyphRects : les trois rectangles blancs du « j », en unités de la grille 12.
var glyphRects = [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}}
// brandIconImage dessine l'icône à la taille n. bg peint le carré arrondi, fg le
// « j ». Les deux peuvent être transparents : c'est ce qui produit l'icône
// « template » de macOS (voir brandTemplatePNG).
func brandIconImage(n int, bg, fg color.RGBA) *image.RGBA {
const r = 2.0 // rayon des coins, en unités de la grille 12
outside := func(gx, gy float64) bool {
corner := func(cx, cy float64) bool {
dx, dy := gx-cx, gy-cy
return dx*dx+dy*dy > r*r
}
switch {
case gx < r && gy < r:
return corner(r, r)
case gx > 12-r && gy < r:
return corner(12-r, r)
case gx < r && gy > 12-r:
return corner(r, 12-r)
case gx > 12-r && gy > 12-r:
return corner(12-r, 12-r)
}
return false
}
img := image.NewRGBA(image.Rect(0, 0, n, n))
scale := float64(n) / 12
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
gx := (float64(x) + 0.5) / scale
gy := (float64(y) + 0.5) / scale
if outside(gx, gy) {
img.Set(x, y, brandClear)
continue
}
c := bg
for _, rc := range glyphRects {
if gx >= rc[0] && gx < rc[0]+rc[2] && gy >= rc[1] && gy < rc[1]+rc[3] {
c = fg
}
}
img.Set(x, y, c)
}
}
return img
}
func encodePNG(img *image.RGBA) []byte {
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// BrandIconPNG rend l'icône de marque (noir + « j » blanc) en PNG de n pixels.
// Exporté pour le générateur d'icône du .exe (tools/gen-icon).
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) }
// brandTemplatePNG rend la variante « template » attendue par macOS : seule la
// couche alpha compte, le système colore la forme selon le thème de la barre de
// menus. Le « j » est donc DÉCOUPÉ (transparent) dans un carré opaque — sans ça,
// une icône entièrement noire disparaît sur une barre de menus sombre.
func brandTemplatePNG(n int) []byte {
return encodePNG(brandIconImage(n, brandBlack, brandClear))
}
// BrandICO emballe une ou plusieurs tailles PNG dans un conteneur .ico. Windows
// accepte le PNG comme image d'une entrée .ico (alpha conservé pour les coins
// arrondis). Plusieurs tailles = un rendu net partout, de la barre des tâches
// (16 px) à la grande tuile de l'explorateur (256 px).
func BrandICO(sizes ...int) []byte {
if len(sizes) == 0 {
sizes = []int{trayIconSize}
}
imgs := make([][]byte, 0, len(sizes))
for _, n := range sizes {
imgs = append(imgs, BrandIconPNG(n))
}
var ico bytes.Buffer
binary.Write(&ico, binary.LittleEndian, uint16(0)) // réservé
binary.Write(&ico, binary.LittleEndian, uint16(1)) // type = icône
binary.Write(&ico, binary.LittleEndian, uint16(len(imgs))) // nombre d'images
offset := uint32(6 + 16*len(imgs)) // fin du répertoire
for i, p := range imgs {
n := sizes[i]
// 256 px se code 0 dans un .ico (le champ ne fait qu'un octet).
ico.WriteByte(byte(n % 256))
ico.WriteByte(byte(n % 256))
ico.WriteByte(0) // couleurs de la palette (0 = truecolor)
ico.WriteByte(0) // réservé
binary.Write(&ico, binary.LittleEndian, uint16(1)) // plans
binary.Write(&ico, binary.LittleEndian, uint16(32)) // bits/pixel
binary.Write(&ico, binary.LittleEndian, uint32(len(p))) // taille données
binary.Write(&ico, binary.LittleEndian, offset) // offset données
offset += uint32(len(p))
}
for _, p := range imgs {
ico.Write(p)
}
return ico.Bytes()
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows
package jean
import "os"
// appFirstRun : hors Windows, `jean app` est lancé depuis un terminal par
// quelqu'un qui a déjà suivi l'installation — pas de double-clic à désambiguïser,
// donc pas de boîte de dialogue. On se contente de provisionner les données au
// premier lancement, comme avant. Ne relance jamais (renvoie toujours false).
func appFirstRun() bool {
if _, err := os.Stat(confPath()); err != nil {
_ = cmdInstall(nil)
}
return false
}
+172
View File
@@ -0,0 +1,172 @@
//go:build windows
package jean
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"unsafe"
)
// Premier lancement sous Windows.
//
// Avant : double-cliquer jean-windows-amd64.exe déclenchait une installation
// SILENCIEUSE (copie du binaire dans %ProgramData%\jean\bin, ajout au PATH) dont
// l'utilisateur ne voyait rien — cmdApp appelait cmdInstall sans console pour en
// afficher la sortie. D'où la confusion légitime : est-ce que ça installe ou est-ce
// que ça lance ? Et surtout, deux copies du binaire coexistaient (celle du Bureau,
// qui tourne, et celle installée) : « mettre à jour » ne touchait que celle lancée,
// tandis que le raccourci et le PATH pointaient sur l'autre, restée en arrière.
//
// Maintenant : on DEMANDE, une bonne fois. Si l'utilisateur accepte, on installe
// pour de bon (copie + PATH + raccourcis menu Démarrer/Bureau) puis on relance
// depuis la copie installée et on quitte — il n'existe alors qu'UN seul binaire qui
// compte, à un emplacement connu, et le bouton de mise à jour agit dessus. S'il
// refuse, on lance l'application telle quelle, sans rien écrire ailleurs que dans
// le dossier de données.
const (
mbYesNo = 0x00000004
mbIconQuestion = 0x00000020
mbIconInfo = 0x00000040
mbTopmost = 0x00040000
idYes = 6
)
var pMessageBoxW = u32s.NewProc("MessageBoxW")
func messageBox(text, title string, flags uintptr) int {
t, _ := syscall.UTF16PtrFromString(text)
c, _ := syscall.UTF16PtrFromString(title)
r, _, _ := pMessageBoxW.Call(0, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(c)), flags|mbTopmost)
return int(r)
}
// appFirstRun prépare le dossier de données et, si le binaire lancé n'est pas
// celui installé, propose l'installation. Renvoie true si l'app a été relancée
// depuis la copie installée (l'appelant doit alors rendre la main immédiatement).
func appFirstRun() bool {
// Le dossier de données et config.env sont indispensables pour que l'UI
// s'ouvre : on les crée sans rien demander, ils ne surprennent personne.
_ = provisionDataDir()
exe, err := os.Executable()
if err != nil {
return false
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
target := installedExePath()
if strings.EqualFold(exe, target) {
return false // on EST déjà l'application installée
}
if _, err := os.Stat(target); err == nil {
// Une copie est déjà installée — typiquement par l'installation silencieuse
// des versions ≤ 0.6.9. On le signale UNE SEULE FOIS : répéter la boîte à
// chaque double-clic du fichier téléchargé serait un harcèlement, pas une
// information.
notice := filepath.Join(JeanHome(), ".install_notice")
if _, seen := os.Stat(notice); seen != nil {
messageBox(
"AJEAN est déjà installé sur cet ordinateur :\n\n"+target+
"\n\nCe fichier-ci n'est qu'une copie téléchargée : vous pouvez la supprimer et lancer AJEAN depuis le menu Démarrer.\n\nCette copie va démarrer normalement.",
"AJEAN", mbIconInfo)
_ = os.WriteFile(notice, []byte(target+"\n"), 0o644)
}
return false
}
if messageBox(
"Installer AJEAN sur cet ordinateur ?\n\n"+
"• le programme sera copié dans :\n "+target+"\n"+
"• un raccourci sera ajouté au menu Démarrer et au Bureau\n"+
"• vos réglages et modèles seront rangés dans :\n "+JeanHome()+"\n\n"+
"Vous pourrez ensuite supprimer le fichier que vous venez de télécharger.\n\n"+
"Répondre Non lance AJEAN sans rien installer.",
"Installation d'AJEAN", mbYesNo|mbIconQuestion) != idYes {
return false
}
if _, err := installSelf(filepath.Dir(target)); err != nil {
messageBox("Installation impossible :\n\n"+err.Error()+"\n\nAJEAN va démarrer depuis l'emplacement actuel.", "AJEAN", mbIconInfo)
return false
}
_, _ = addToUserPath(filepath.Dir(target))
shortcuts := createShortcuts(target)
messageBox("AJEAN est installé.\n\n"+target+"\n\n"+shortcuts+
"\n\nL'application va démarrer. Vous pouvez supprimer le fichier téléchargé.",
"AJEAN", mbIconInfo)
// Relance depuis la copie installée : à partir de maintenant, une seule et
// même image du programme se met à jour, se relance et apparaît dans le tray.
cmd := exec.Command(target)
cmd.Dir = filepath.Dir(target)
if err := cmd.Start(); err != nil {
return false // échec du relancement : on continue avec la copie courante
}
return true
}
// provisionDataDir crée le dossier de données et un config.env de départ.
// Extrait de cmdInstall pour être réutilisable au premier lancement, sans
// embarquer l'installation du binaire ni les messages de console.
func provisionDataDir() error {
jeanHome := JeanHome()
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
conf := filepath.Join(jeanHome, "config.env")
if _, err := os.Stat(conf); os.IsNotExist(err) {
return os.WriteFile(conf, []byte(configTemplate), 0o644)
}
return nil
}
// createShortcuts pose un raccourci « AJEAN » dans le menu Démarrer et sur le
// Bureau. Renvoie une phrase décrivant ce qui a été créé (affichée à
// l'utilisateur). Best-effort : un échec n'empêche pas l'installation.
func createShortcuts(target string) string {
ps := fmt.Sprintf(`$t=%s
$w=New-Object -ComObject WScript.Shell
$done=@()
foreach ($d in @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('Desktop'))) {
if (-not $d) { continue }
try {
$s=$w.CreateShortcut((Join-Path $d 'AJEAN.lnk'))
$s.TargetPath=$t
$s.WorkingDirectory=(Split-Path $t)
$s.Description='AJEAN — IA locale'
$s.Save()
$done+=$d
} catch {}
}
Write-Output ($done -join ';')`, psQuote(target))
cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
out, err := cmd.CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) == "" {
return "(raccourcis non créés — lancez AJEAN depuis " + target + ")"
}
return "Raccourci « AJEAN » ajouté au menu Démarrer et au Bureau."
}
// removeShortcuts efface les raccourcis posés par createShortcuts. Renvoie true
// si au moins un a été supprimé.
func removeShortcuts() bool {
ps := `$n=0
foreach ($d in @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('Desktop'))) {
if (-not $d) { continue }
$p=Join-Path $d 'AJEAN.lnk'
if (Test-Path $p) { try { Remove-Item $p -Force; $n++ } catch {} }
}
Write-Output $n`
out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).CombinedOutput()
return err == nil && strings.TrimSpace(string(out)) != "0"
}
+15 -14
View File
@@ -44,22 +44,18 @@ func cmdInstall(args []string) error {
fmt.Printf(" JEAN_HOME = %s\n", jeanHome)
fmt.Printf(" service = %s\n", serviceName())
// 1. Create directories
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
// 2. Drop a config.env if none exists
// 1-2. Dossiers + config.env de départ (partagé avec le premier lancement de
// l'app, voir provisionDataDir dans sys_firstrun_windows.go).
conf := filepath.Join(jeanHome, "config.env")
if _, err := os.Stat(conf); os.IsNotExist(err) {
if err := os.WriteFile(conf, []byte(configTemplate), 0o644); err != nil {
return err
}
fmt.Printf(" %s écrit %s\n", green("✓"), conf)
} else {
_, statErr := os.Stat(conf)
alreadyThere := statErr == nil
if err := provisionDataDir(); err != nil {
return err
}
if alreadyThere {
fmt.Printf(" %s config.env déjà présent — inchangé\n", dim("•"))
} else {
fmt.Printf(" %s écrit %s\n", green("✓"), conf)
}
// 3. Install the binary into JEAN_HOME\bin and put that dir on the user PATH,
@@ -203,6 +199,11 @@ func cmdUninstall(args []string) error {
fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir)
}
// Raccourcis posés à l'installation (menu Démarrer + Bureau).
if removeShortcuts() {
fmt.Printf(" %s raccourcis « AJEAN » supprimés\n", green("✓"))
}
if !keepData {
jeanHome := JeanHome()
if err := os.RemoveAll(jeanHome); err != nil {
+79
View File
@@ -0,0 +1,79 @@
package jean
import (
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
)
// « Où sont mes fichiers ? » — la question que personne ne devrait avoir à poser.
// Un binaire unique qui s'auto-installe au premier lancement rend les
// emplacements invisibles : on ne sait plus si le .exe qu'on a double-cliqué EST
// l'application ou seulement un installateur, ni où atterrissent la config, les
// modèles et les fichiers que l'agent écrit. On centralise donc la réponse ici,
// et on l'expose à la fois en ligne de commande (`jean where`) et dans l'UI.
type jeanPaths struct {
Home string `json:"home"` // JEAN_HOME : racine des données
Config string `json:"config"` // config.env
Exe string `json:"exe"` // binaire en cours d'exécution
Installed string `json:"installed"` // binaire installé (peut différer de Exe)
Workspace string `json:"workspace"` // dossier de travail du mode agent
Memory string `json:"memory"`
Presets string `json:"presets"`
}
// installedExePath est l'emplacement canonique du binaire après installation.
// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/jean
// (c'est ce que référencent les unités systemd et le plist launchd) ; sous Windows,
// faute d'équivalent, on utilise JEAN_HOME\bin ajouté au PATH utilisateur.
func installedExePath() string {
if runtime.GOOS == "windows" {
return filepath.Join(JeanHome(), "bin", "jean.exe")
}
return "/usr/local/bin/jean"
}
func currentPaths() jeanPaths {
exe, _ := os.Executable()
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return jeanPaths{
Home: JeanHome(),
Config: confPath(),
Exe: exe,
Installed: installedExePath(),
Workspace: agentWorkspace(),
Memory: memoryDir(),
Presets: presetsDir(),
}
}
func cmdWhere(args []string) error {
p := currentPaths()
fmt.Printf("Emplacements AJEAN\n\n")
for _, row := range [][2]string{
{"données (JEAN_HOME)", p.Home},
{"configuration", p.Config},
{"binaire en cours", p.Exe},
{"binaire installé", p.Installed},
{"travail de l'agent", p.Workspace},
{"mémoire", p.Memory},
{"presets", p.Presets},
} {
fmt.Printf(" %-20s %s\n", row[0], row[1])
}
if p.Exe != p.Installed {
fmt.Printf("\n%s tu exécutes une copie qui n'est PAS le binaire installé.\n", dim("[info]"))
fmt.Printf(" Les mises à jour depuis l'application ne modifient que la copie lancée.\n")
}
return nil
}
// handlePaths (GET /api/paths) alimente le bloc « Emplacements » de l'UI.
func handlePaths(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, currentPaths())
}
+10 -7
View File
@@ -94,7 +94,9 @@ type wndClassExW struct {
}
const (
colBlue = 0x00EB6F1F // COLORREF = 0x00BBGGRR pour #1f6feb
// Fond noir + « j » blanc : même marque que le favicon et que les icônes
// système (voir sys_brand_icon.go). L'écran de démarrage restait bleu.
colBrand = 0x00000000 // COLORREF = 0x00BBGGRR
colWhite = 0x00FFFFFF
)
@@ -212,18 +214,19 @@ func splashWndProc(hwnd, message, wParam, lParam uintptr) uintptr {
}
func paintSplash(hdc uintptr) {
// Fond bleu.
blue, _, _ := pCreateSolidBrush.Call(colBlue)
// Fond aux couleurs de la marque.
bg, _, _ := pCreateSolidBrush.Call(colBrand)
full := rect{0, 0, splashW, splashH}
pFillRect.Call(hdc, uintptr(unsafe.Pointer(&full)), blue)
pDeleteObject.Call(blue)
pFillRect.Call(hdc, uintptr(unsafe.Pointer(&full)), bg)
pDeleteObject.Call(bg)
// Logo « j » blanc (rects du favicon sur grille 12, mis à l'échelle).
// Logo « j » blanc — mêmes rectangles que l'icône et le favicon
// (glyphRects, sys_brand_icon.go), mis à l'échelle.
white, _, _ := pCreateSolidBrush.Call(colWhite)
const logo = 58
ox, oy := int32(40), int32((splashH-logo)/2)
scale := float64(logo) / 12
for _, rc := range [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}} {
for _, rc := range glyphRects {
r := rect{
left: ox + int32(rc[0]*scale),
top: oy + int32(rc[1]*scale),
+4 -1
View File
@@ -22,7 +22,10 @@ import (
// À appeler depuis la goroutine principale : Cocoa exige le thread main.
func runTray(url string) {
systray.Run(func() {
systray.SetIcon(trayIconPNG())
// Icône « template » : macOS n'en garde que la forme (couche alpha) et la
// colore selon le thème. Indispensable depuis que la marque est noire —
// une icône noire « en dur » est illisible sur une barre de menus sombre.
systray.SetTemplateIcon(brandTemplatePNG(trayIconSize), BrandIconPNG(trayIconSize))
systray.SetTooltip("AJEAN — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface")
systray.AddSeparator()
-69
View File
@@ -1,69 +0,0 @@
//go:build windows || darwin
package jean
// sys_tray_icon.go — icône de Jean pour la zone de notification (Windows) et la
// barre de menus (macOS). RÉPLIQUE EXACTE du favicon de l'UI web : carré bleu
// arrondi #1f6feb + « j » blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12).
// Générée à la volée — pas d'asset binaire à embarquer.
import (
"bytes"
"image"
"image/color"
"image/png"
)
const trayIconSize = 32
// trayIconPNG rend l'icône en PNG 32x32 avec alpha (coins arrondis).
func trayIconPNG() []byte {
const n = 32
blue := color.RGBA{0x1f, 0x6f, 0xeb, 0xff}
white := color.RGBA{0xff, 0xff, 0xff, 0xff}
clear := color.RGBA{0, 0, 0, 0}
const r = 2.0 // rayon des coins, en unités de la grille 12
// rects blancs du favicon (x, y, w, h en unités 12)
rects := [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}}
// hors du rectangle à coins arrondis (grille 12) → pixel transparent
outside := func(gx, gy float64) bool {
corner := func(cx, cy float64) bool {
dx, dy := gx-cx, gy-cy
return dx*dx+dy*dy > r*r
}
switch {
case gx < r && gy < r:
return corner(r, r)
case gx > 12-r && gy < r:
return corner(12-r, r)
case gx < r && gy > 12-r:
return corner(r, 12-r)
case gx > 12-r && gy > 12-r:
return corner(12-r, 12-r)
}
return false
}
img := image.NewRGBA(image.Rect(0, 0, n, n))
scale := float64(n) / 12
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
gx := (float64(x) + 0.5) / scale
gy := (float64(y) + 0.5) / scale
if outside(gx, gy) {
img.Set(x, y, clear)
continue
}
c := blue
for _, rc := range rects {
if gx >= rc[0] && gx < rc[0]+rc[2] && gy >= rc[1] && gy < rc[1]+rc[3] {
c = white
}
}
img.Set(x, y, c)
}
}
var pngBuf bytes.Buffer
_ = png.Encode(&pngBuf, img)
return pngBuf.Bytes()
}
+4 -23
View File
@@ -12,8 +12,6 @@ package jean
// donc CGO) ; les builds Linux ne touchent jamais systray.
import (
"bytes"
"encoding/binary"
"os"
"github.com/getlantern/systray"
@@ -46,24 +44,7 @@ func runTray(url string) {
})
}
// trayIcon emballe le PNG de trayIconPNG() dans un conteneur .ico (ICONDIR +
// une ICONDIRENTRY) : Windows accepte un PNG (avec alpha pour les coins
// arrondis) comme image d'un .ico.
func trayIcon() []byte {
p := trayIconPNG()
const n = trayIconSize
var ico bytes.Buffer
binary.Write(&ico, binary.LittleEndian, uint16(0)) // réservé
binary.Write(&ico, binary.LittleEndian, uint16(1)) // type = icône
binary.Write(&ico, binary.LittleEndian, uint16(1)) // nombre d'images
ico.WriteByte(n) // largeur
ico.WriteByte(n) // hauteur
ico.WriteByte(0) // couleurs
ico.WriteByte(0) // réservé
binary.Write(&ico, binary.LittleEndian, uint16(1)) // plans
binary.Write(&ico, binary.LittleEndian, uint16(32)) // bits/pixel
binary.Write(&ico, binary.LittleEndian, uint32(len(p))) // taille données
binary.Write(&ico, binary.LittleEndian, uint32(6+16)) // offset données
ico.Write(p)
return ico.Bytes()
}
// trayIcon : l'icône de marque en .ico, deux tailles pour rester nette selon
// l'échelle d'affichage. Le rendu vit dans sys_brand_icon.go, partagé avec
// l'icône du .exe.
func trayIcon() []byte { return BrandICO(16, trayIconSize) }
+39 -1
View File
@@ -134,9 +134,20 @@ func applyUpdate() (string, error) {
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
// Contrôle des droits AVANT de télécharger : sans ça, l'échec survient sur le
// os.Create du fichier temporaire et l'utilisateur reçoit un « open
// /usr/local/bin/.jean-update.tmp: permission denied » incompréhensible,
// notamment depuis le bouton de l'UI web (jean web lancé sans sudo).
if err := checkUpdateWritable(exe); err != nil {
return "", err
}
tmp := filepath.Join(filepath.Dir(exe), ".jean-update.tmp")
if err := downloadTo(url, tmp); err != nil {
os.Remove(tmp)
if os.IsPermission(err) {
return "", updatePermissionError(exe)
}
return "", fmt.Errorf("téléchargement : %w", err)
}
if err := verifyChecksum(rel, want, tmp); err != nil {
@@ -158,13 +169,40 @@ func applyUpdate() (string, error) {
if err := replaceBinary(exe, tmp); err != nil {
os.Remove(tmp)
if os.IsPermission(err) {
return "", fmt.Errorf("droits insuffisants pour écrire %s — relance avec privilèges (ex : sudo jean update)", exe)
return "", updatePermissionError(exe)
}
return "", err
}
return strings.TrimPrefix(latest, "v"), nil
}
// updatePermissionError formule le seul message utile quand jean n'a pas les
// droits d'écrire son propre binaire : la commande exacte à lancer.
func updatePermissionError(exe string) error {
if runtime.GOOS == "windows" {
return fmt.Errorf("droits insuffisants pour remplacer %s — relance AJEAN en administrateur puis réessaie", exe)
}
return fmt.Errorf("droits insuffisants pour remplacer %s (le binaire appartient à root) — lance la mise à jour en ligne de commande : sudo jean update", exe)
}
// checkUpdateWritable vérifie qu'on peut écrire dans le dossier du binaire, en
// créant réellement un fichier témoin (les bits de permission seuls ne suffisent
// pas : root, groupes, ACL, montage en lecture seule…).
func checkUpdateWritable(exe string) error {
dir := filepath.Dir(exe)
probe, err := os.CreateTemp(dir, ".jean-update-probe-*")
if err != nil {
if os.IsPermission(err) {
return updatePermissionError(exe)
}
return fmt.Errorf("impossible d'écrire dans %s : %w", dir, err)
}
name := probe.Name()
probe.Close()
os.Remove(name)
return nil
}
func cmdUpdate(args []string) error {
checkOnly := false
for _, a := range args {
+23 -1
View File
@@ -1059,6 +1059,8 @@ button:hover{border-color:var(--dim);color:var(--text)}
<div class="slist pad">
<div class="row"><button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button></div>
<div id="upd-msg" class="muted"></div>
<div class="row"><button onclick="showPaths()">📁 Où sont mes fichiers ?</button></div>
<div id="paths-msg" class="muted"></div>
<div class="row">
<button onclick="loadAll()">↻ refresh</button>
<button onclick="resetChat()">⌫ clear chat</button>
@@ -1659,11 +1661,31 @@ async function checkUpdate(){
}catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false;
}
// Emplacements : la question « où sont mes fichiers / est-ce que ça a été
// installé ? » revient assez souvent pour mériter une réponse dans l'UI.
async function showPaths(){
const el=document.getElementById('paths-msg');
el.textContent='…';
try{
const p=await jget('/api/paths');
const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'agent',p.workspace],['Mémoire',p.memory],['Programme en cours',p.exe]];
el.innerHTML=rows.map(r=>'<div><b>'+r[0]+'</b><br><code>'+escHtml(r[1]||'')+'</code></div>').join('');
if(p.exe && p.installed && p.exe!==p.installed){
el.innerHTML+='<div style="color:var(--warn);margin-top:8px">Vous exécutez une copie du programme, pas celle installée (<code>'+escHtml(p.installed)+'</code>). La mise à jour ne modifiera que la copie lancée.</div>';
}
}catch(e){ el.textContent='Erreur'; }
}
async function applyUpdate(){
const msg=document.getElementById('upd-msg');
msg.textContent='Téléchargement et installation…';
try{
const r=await jpost('/api/update/apply',{});
// Signal dédié : le timeout par défaut (30 s) coupe le téléchargement du
// binaire sur une connexion lente et fait croire à un échec alors que la
// mise à jour aboutit côté serveur.
const ac=new AbortController(); const t=setTimeout(()=>ac.abort(), 10*60*1000);
let r;
try{ r=await (await jfetch('/api/update/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',signal:ac.signal})).json(); }
finally{ clearTimeout(t); }
if(r.ok){
msg.innerHTML='✓ Installé en <b>v'+r.version+'</b>.<br>'+(r.restart||'');
// Redémarrage auto du service côté serveur : le flux va se couper puis
+2
View File
@@ -231,6 +231,8 @@
<div class="slist pad">
<div class="row"><button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button></div>
<div id="upd-msg" class="muted"></div>
<div class="row"><button onclick="showPaths()">📁 Où sont mes fichiers ?</button></div>
<div id="paths-msg" class="muted"></div>
<div class="row">
<button onclick="loadAll()">↻ refresh</button>
<button onclick="resetChat()">⌫ clear chat</button>
+21 -1
View File
@@ -61,11 +61,31 @@ async function checkUpdate(){
}catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false;
}
// Emplacements : la question « où sont mes fichiers / est-ce que ça a été
// installé ? » revient assez souvent pour mériter une réponse dans l'UI.
async function showPaths(){
const el=document.getElementById('paths-msg');
el.textContent='…';
try{
const p=await jget('/api/paths');
const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'agent',p.workspace],['Mémoire',p.memory],['Programme en cours',p.exe]];
el.innerHTML=rows.map(r=>'<div><b>'+r[0]+'</b><br><code>'+escHtml(r[1]||'')+'</code></div>').join('');
if(p.exe && p.installed && p.exe!==p.installed){
el.innerHTML+='<div style="color:var(--warn);margin-top:8px">Vous exécutez une copie du programme, pas celle installée (<code>'+escHtml(p.installed)+'</code>). La mise à jour ne modifiera que la copie lancée.</div>';
}
}catch(e){ el.textContent='Erreur'; }
}
async function applyUpdate(){
const msg=document.getElementById('upd-msg');
msg.textContent='Téléchargement et installation…';
try{
const r=await jpost('/api/update/apply',{});
// Signal dédié : le timeout par défaut (30 s) coupe le téléchargement du
// binaire sur une connexion lente et fait croire à un échec alors que la
// mise à jour aboutit côté serveur.
const ac=new AbortController(); const t=setTimeout(()=>ac.abort(), 10*60*1000);
let r;
try{ r=await (await jfetch('/api/update/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',signal:ac.signal})).json(); }
finally{ clearTimeout(t); }
if(r.ok){
msg.innerHTML='✓ Installé en <b>v'+r.version+'</b>.<br>'+(r.restart||'');
// Redémarrage auto du service côté serveur : le flux va se couper puis
+1
View File
@@ -89,6 +89,7 @@ func newWebMux() *http.ServeMux {
api("/api/ram", handleRam)
api("/api/config", handleConfigEnv)
api("/api/catalog", handleCatalog)
api("/api/paths", handlePaths)
api("/api/update", handleUpdateCheck)
api("/api/update/apply", handleUpdateApply)
api("/api/models", handleModels)