mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
v0.8.4 : l'historique s'exporte, le moteur se laisse joindre, les modeles en plusieurs fichiers marchent
Trois retours d'utilisateurs apres la 0.8.3. Export de la conversation (regression de la 0.8 : conversation.json a disparu dans ajean.db, verrouille et binaire). Bouton dans l'UI + commande 'ajean export [--json] [fichier]', endpoint /api/chat/export. Ecoute reseau du moteur : interrupteur 'joignable depuis le reseau local' dans le panneau Acces OpenAI (HOST + regle de pare-feu Windows), commande 'ajean network [on|off|status]'. HOST devient un reglage de machine preserve au changement de preset. Modeles GGUF decoupes en tranches -00001-of-000NN : une famille = un modele. Telechargement de toutes les tranches depuis n'importe quel lien, sonde d'espace disque sur le total, liste et suppression regroupees, demarrage refuse avec le nom du fichier manquant.
This commit is contained in:
1 parent
acf11ff66a
commit
52baab5786
26 files changed
+1414
-107
No files matched your search
+34
-15
@@ -1,32 +1,49 @@
|
||||
Deux bugs signalés par l'usage, tous les deux confirmés, tous les deux réparés. Le premier pouvait bloquer le chat jusqu'au redémarrage du service. Le second faisait mentir un interrupteur.
|
||||
Trois retours d'utilisateurs après la 0.8.3, trois vrais trous dans le logiciel. Ils sont bouchés.
|
||||
|
||||
## Le bouton stop arrête vraiment, et le chat ne se bloque plus
|
||||
## Récupérer sa conversation
|
||||
|
||||
Le scénario, tel qu'il était vécu : le modèle lance une commande, elle dure, on clique sur stop, rien ne se passe. On redémarre alors le moteur, et là le chat reste figé avec son bouton stop, sans plus rien accepter. Vider la conversation ne suffit pas, rafraîchir la page non plus ; il faut redémarrer le service d'interface.
|
||||
Jusqu'à la 0.7, l'historique vivait dans un fichier `conversation.json` qu'on pouvait ouvrir, relire et copier pour archive. La 0.8 l'a rangé dans `ajean.db`, une base binaire, verrouillée tant que le service tourne : impossible ne serait ce que de la copier. Le fil et les raisonnements étaient toujours là, mais plus personne ne pouvait les sortir. C'était une régression, et elle n'aurait pas dû passer.
|
||||
|
||||
Trois défauts se cumulaient, et ils sont corrigés ensemble.
|
||||
Deux façons de récupérer le fil, désormais.
|
||||
|
||||
**La commande ignorait l'arrêt.** Elle démarrait avec un contexte à elle, indépendant du tour. Annuler la génération n'annulait donc rien du tout : le tour restait suspendu jusqu'au bout du délai, cinq minutes au maximum. La commande hérite désormais du contexte du tour, et stop la tue pour de bon. Dans la foulée, un arrêt demandé au milieu d'une série d'appels d'outils interrompt la série au lieu de la dérouler jusqu'au bout.
|
||||
Depuis l'interface, dans le panneau « Actions » : un bouton exporte la conversation en Markdown (les raisonnements sont repliés sous un « Raisonnement », les appels d'outils apparaissent avec leur résultat), un autre en JSON fidèle si vous voulez retraiter les données.
|
||||
|
||||
**Une commande qui laisse un processus en arrière-plan bloquait le tour pour toujours.** Quelque chose comme `./serveur &` rend la main tout de suite, mais le processus détaché garde les tubes de sortie ouverts, et l'attente de fin de commande attendait leur fermeture, c'est à dire jamais. Ni le délai ni le stop n'en venaient à bout. L'attente est maintenant bornée après la fin ou la mise à mort du processus.
|
||||
Depuis le terminal :
|
||||
|
||||
**Vider la conversation ne débloquait pas.** C'est pourtant le geste qu'on tente quand le chat est coincé. « Nouvelle conversation » libère désormais toujours l'état de génération, quel que soit le sort du tour abandonné. Plus besoin de redémarrer quoi que ce soit.
|
||||
```bash
|
||||
ajean export # ajean-conversation-<date>.md
|
||||
ajean export --json # même chose en JSON
|
||||
ajean export mon-fil.md # nom imposé, l'extension choisit le format
|
||||
ajean export - # sur la sortie standard, pour enchaîner un tube
|
||||
```
|
||||
|
||||
Au passage, un tour abandonné qui se termine après le démarrage du suivant ne vient plus déclarer ce dernier terminé.
|
||||
La commande fonctionne pendant que le service tourne.
|
||||
|
||||
## Un troisième défaut, trouvé en écrivant les tests
|
||||
## Le moteur joignable depuis le réseau
|
||||
|
||||
Le dossier de travail de l'agent est résolu une fois pour toutes au démarrage. S'il disparaissait ensuite, parce que vous avez fait le ménage ou parce que le modèle l'a supprimé lui-même, **toutes** les commandes suivantes échouaient sur un « chdir : no such file or directory » incompréhensible, et ce jusqu'au redémarrage. Il est maintenant recréé au besoin.
|
||||
Symptôme rapporté : « à part le chat dans le navigateur, impossible d'utiliser ton URL dans les logiciels en local ». L'adresse `http://<machine>:8080/v1` s'affichait bien dans l'interface, mais rien ne répondait depuis un autre ordinateur.
|
||||
|
||||
## Raisonnement désactivé veut enfin dire désactivé
|
||||
Deux causes, cumulées, et aucune des deux n'était visible.
|
||||
|
||||
Couper le raisonnement dans l'éditeur de preset **effaçait** la ligne `REASONING` au lieu d'écrire `off`. Ce n'est pas la même chose : sans consigne, le moteur suit le gabarit du modèle, et un modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé » pendant que le modèle réfléchissait tranquillement.
|
||||
Sous Windows, l'installation posait `HOST=127.0.0.1` : le moteur n'écoutait que sur la machine elle même. Le chat du navigateur marchait, puisqu'il passe par le serveur web d'AJEAN, sur place. Tout le reste était invisible. Et ce réglage n'était modifiable nulle part dans l'interface, il fallait connaître `ajean edit` et savoir quoi y écrire.
|
||||
|
||||
L'interface écrit maintenant `on` ou `off`, explicitement, et `off` est transmis au moteur comme une interdiction.
|
||||
Deuxième cause : même ouvert sur toutes les interfaces, le pare feu Windows bloque les connexions entrantes tant qu'aucune règle n'autorise le port. AJEAN n'en posait aucune.
|
||||
|
||||
Avec une précaution : le drapeau qui désactive le raisonnement est récent, et certains moteurs, notamment le fork ik_llama.cpp, ne le connaissent pas. Le leur passer les ferait refuser de démarrer, donc boucler. AJEAN demande au binaire ce qu'il sait faire avant de le lui passer, et le dit dans le journal quand le moteur choisi ne permet pas de couper la réflexion.
|
||||
Il y a maintenant un interrupteur « joignable depuis le réseau local », dans le panneau « Accès OpenAI », juste sous l'adresse qu'il conditionne. Il règle l'adresse d'écoute et pose la règle de pare feu dans le même geste. Poser une règle exige les droits administrateur, que l'installation d'AJEAN ne réclame pas : quand ça échoue, l'interface le dit et donne la commande exacte à coller dans un terminal administrateur, au lieu de laisser croire que c'est fait.
|
||||
|
||||
Vos presets existants ne sont pas modifiés. Ceux dont la ligne est absente gardent le comportement d'avant, et l'éditeur ne prétend plus que le raisonnement y est coupé : il indique que rien n'est précisé et que le modèle décide. Basculez l'interrupteur une fois pour trancher.
|
||||
En ligne de commande : `ajean network`, `ajean network on`, `ajean network off`. Le moteur doit redémarrer pour appliquer, l'interface le propose.
|
||||
|
||||
L'adresse d'écoute est aussi devenue un réglage de machine et non de modèle : basculer sur un preset écrit avant cette version ne la remet plus à zéro. Une machine volontairement fermée reste fermée.
|
||||
|
||||
## Les modèles en plusieurs fichiers
|
||||
|
||||
Au delà d'une certaine taille, un dépôt Hugging Face publie son GGUF en tranches : `...-00001-of-00003.gguf`, et ainsi de suite. llama.cpp n'a besoin que de la première, il ouvre les suivantes tout seul. AJEAN, lui, traitait chaque tranche comme un modèle indépendant, avec trois conséquences.
|
||||
|
||||
Le lien collé ne rapatriait qu'un fichier sur trois, et le moteur mourait ensuite sur un tenseur introuvable. Le sélecteur affichait trois entrées pour un seul modèle, dont deux qui ne démarrent pas. Et supprimer « le modèle » n'effaçait que sa première tranche, laissant des dizaines de Go que plus rien ne référençait et que l'interface ne savait plus montrer.
|
||||
|
||||
Une famille de tranches est maintenant un seul modèle. Coller le lien de n'importe laquelle télécharge la famille entière, avec une barre de progression unique et un compteur de fichiers ; la vérification d'espace disque porte sur le total, plus sur une tranche. La liste n'affiche que la première, avec la taille de l'ensemble et une mention « 3 fichiers ». S'il en manque une, elle est signalée dans la liste, et le démarrage du moteur s'arrête sur un message qui nomme le fichier absent au lieu de laisser le service boucler. La suppression emporte toutes les tranches.
|
||||
|
||||
Un téléchargement interrompu à la deuxième tranche peut être relancé : les fichiers déjà complets sont conservés.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
@@ -35,3 +52,5 @@ ajean update
|
||||
```
|
||||
|
||||
Puis, si vous utilisez l'accès distant, rechargez la page du portail pour prendre la nouvelle interface.
|
||||
|
||||
Ce qui n'a pas été vérifié sur machine réelle : le téléchargement d'un vrai modèle découpé de bout en bout (la logique est couverte par des tests, mais pas un transfert complet depuis Hugging Face), et le comportement sur macOS, toujours pas testé sur un Mac.
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,13 +3,13 @@
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 8,
|
||||
"Patch": 3,
|
||||
"Patch": 4,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 8,
|
||||
"Patch": 3,
|
||||
"Patch": 4,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
|
||||
"OriginalFilename": "ajean.exe",
|
||||
"ProductName": "AJEAN",
|
||||
"ProductVersion": "0.8.3",
|
||||
"ProductVersion": "0.8.4",
|
||||
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -167,7 +167,10 @@ func modelDirCount(dir string) int {
|
||||
}
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") {
|
||||
// Les tranches 2..N d'un modèle découpé ne comptent pas pour un modèle :
|
||||
// sinon « 3 modèles » s'affichait pour un seul, celui qui tient en trois
|
||||
// fichiers. Même règle que la liste du sélecteur (handleModels).
|
||||
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") && !isFollowerShard(e.Name()) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,10 @@ func modelFromPresetContent(content string) string {
|
||||
}
|
||||
|
||||
// deleteModelFile removes a .gguf file from one of the declared model folders
|
||||
// after validating the name.
|
||||
// after validating the name. Un modèle découpé emporte TOUTES ses tranches :
|
||||
// n'effacer que la première laissait des dizaines de Go de fichiers que plus
|
||||
// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
|
||||
// tranches suivantes n'y apparaissent pas).
|
||||
func deleteModelFile(name string) error {
|
||||
p, err := resolveModelPath(name)
|
||||
if err != nil {
|
||||
@@ -136,6 +139,10 @@ func deleteModelFile(name string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(p)
|
||||
for _, n := range shardFamily(filepath.Base(p)) {
|
||||
_ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -166,6 +173,8 @@ type dlState struct {
|
||||
Done int64 `json:"done"`
|
||||
Speed int64 `json:"speed"` // bytes/s, smoothed over the last samples
|
||||
Conns int `json:"conns"` // parallel connections actually used
|
||||
Parts int `json:"parts"` // nombre de tranches (1 pour un modèle en un seul fichier)
|
||||
Part int `json:"part"` // tranche en cours (1-based)
|
||||
Finished bool `json:"finished"`
|
||||
Canceled bool `json:"canceled"`
|
||||
Err string `json:"error"`
|
||||
@@ -263,34 +272,56 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
dest, err := downloadDestPath(name, req.Dir)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
// Modèle découpé : le lien ne désigne qu'une tranche, on rapatrie la famille.
|
||||
// Le modèle porte le nom de sa PREMIÈRE tranche — c'est elle qu'on passe à
|
||||
// llama-server, et c'est donc elle qui identifie le téléchargement.
|
||||
urls, names := shardURLSet(dlURL, name)
|
||||
name = names[0]
|
||||
dests := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
if dests[i], err = downloadDestPath(n, req.Dir); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dests[0]), 0o755); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "dossier de destination inaccessible : " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Tranches déjà là : on ne les retélécharge pas. C'est aussi ce qui permet de
|
||||
// relancer un téléchargement en plusieurs fichiers interrompu à la deuxième
|
||||
// tranche sans repayer les 15 Go de la première.
|
||||
var todoURLs, todoDests []string
|
||||
for i := range names {
|
||||
if st, err := os.Stat(dests[i]); err == nil && !st.IsDir() {
|
||||
continue
|
||||
}
|
||||
todoURLs = append(todoURLs, urls[i])
|
||||
todoDests = append(todoDests, dests[i])
|
||||
}
|
||||
|
||||
dlMu.Lock()
|
||||
if st, ok := dlDownloads[name]; ok && !st.Finished {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "téléchargement déjà en cours pour " + name})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
if len(todoURLs) == 0 {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
st := &dlState{Filename: name, URL: dlURL, Dir: filepath.Dir(dest), StartedAt: time.Now().Unix(), cancel: cancel}
|
||||
st := &dlState{
|
||||
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
|
||||
}
|
||||
dlDownloads[name] = st
|
||||
dlMu.Unlock()
|
||||
|
||||
go runDownload(ctx, st, dlURL, dest)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name})
|
||||
go runDownloadSet(ctx, st, todoURLs, todoDests)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "parts": len(todoURLs)})
|
||||
}
|
||||
|
||||
// dlSpaceMargin est la marge laissée libre après le téléchargement : un disque
|
||||
@@ -346,14 +377,23 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Un modèle découpé se sonde EN ENTIER : annoncer les 15 Go de la première
|
||||
// tranche alors qu'il en faut 45 sur le disque, c'est promettre que ça tient
|
||||
// puis échouer au deux tiers du transfert.
|
||||
urls, names := shardURLSet(dlURL, name)
|
||||
name = names[0]
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
total, _, err := dlProbe(ctx, dlURL)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
var total int64
|
||||
for _, u := range urls {
|
||||
n, _, err := dlProbe(ctx, u)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
total += n
|
||||
}
|
||||
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true}
|
||||
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true, "parts": len(urls)}
|
||||
if err := checkDiskSpace(dir, total); err != nil {
|
||||
out["enough"] = false
|
||||
out["error"] = err.Error()
|
||||
@@ -420,15 +460,18 @@ func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err e
|
||||
}
|
||||
}
|
||||
|
||||
// runDownload fetches the URL into a .part file then renames it on success.
|
||||
// When the source honours byte ranges (Hugging Face's CDN does), the file is
|
||||
// split across several connections written in place with WriteAt, which is what
|
||||
// makes a multi-GB .gguf saturate the link instead of a single TCP stream.
|
||||
// Cancelling the context aborts every worker and the partial .part is removed,
|
||||
// so a cancelled download leaves nothing behind.
|
||||
func runDownload(ctx context.Context, st *dlState, dlURL, dest string) {
|
||||
tmp := dest + ".part"
|
||||
var done int64 // atomic: total bytes written across all workers
|
||||
// runDownloadSet fetches one model, qui peut tenir en plusieurs fichiers (un
|
||||
// GGUF découpé en tranches). Les tranches se suivent SÉQUENTIELLEMENT — chacune
|
||||
// sature déjà le lien à elle seule grâce aux connexions parallèles, les mener de
|
||||
// front ne ferait que multiplier les fichiers à jeter en cas d'annulation.
|
||||
// La progression publiée (Done / Total) couvre l'ENSEMBLE : une barre unique du
|
||||
// début à la fin, pas trois barres qui repartent de zéro.
|
||||
//
|
||||
// Annulation ou échec : toutes les tranches déjà écrites par CE téléchargement
|
||||
// sont supprimées. Un modèle amputé d'une tranche ne démarre pas ; le laisser
|
||||
// sur le disque n'offrirait qu'un modèle mort et des dizaines de Go occupés.
|
||||
func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
|
||||
var done int64 // atomique : octets écrits, toutes tranches confondues
|
||||
|
||||
finish := func(e error) {
|
||||
dlMu.Lock()
|
||||
@@ -447,54 +490,33 @@ func runDownload(ctx context.Context, st *dlState, dlURL, dest string) {
|
||||
dlMu.Unlock()
|
||||
}
|
||||
|
||||
total, ranged, err := dlProbe(ctx, dlURL)
|
||||
if err != nil {
|
||||
finish(err)
|
||||
return
|
||||
// Sonde de TOUTES les tranches avant d'écrire quoi que ce soit : la place
|
||||
// disque se vérifie sur le total, pas tranche par tranche.
|
||||
totals := make([]int64, len(urls))
|
||||
rangeds := make([]bool, len(urls))
|
||||
var grand int64
|
||||
for i, u := range urls {
|
||||
n, ranged, err := dlProbe(ctx, u)
|
||||
if err != nil {
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
totals[i], rangeds[i] = n, ranged
|
||||
grand += n
|
||||
}
|
||||
// Vérification serveur : l'UI a déjà prévenu, mais rien ne garantit qu'elle
|
||||
// l'ait fait (autre client, disque rempli entre-temps).
|
||||
if err := checkDiskSpace(filepath.Dir(dest), total); err != nil {
|
||||
if err := checkDiskSpace(filepath.Dir(dests[0]), grand); err != nil {
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
|
||||
conns := 1
|
||||
if ranged && total > 0 {
|
||||
conns = dlConns()
|
||||
if max := int((total + dlMinChunk - 1) / dlMinChunk); conns > max {
|
||||
conns = max
|
||||
}
|
||||
if conns < 1 {
|
||||
conns = 1
|
||||
}
|
||||
}
|
||||
|
||||
dlMu.Lock()
|
||||
st.Total = total
|
||||
st.Conns = conns
|
||||
st.Total = grand
|
||||
st.Parts = len(urls)
|
||||
dlMu.Unlock()
|
||||
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
fail := func(e error) {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
finish(e)
|
||||
}
|
||||
if conns > 1 {
|
||||
// Preallocate so the filesystem can lay the file out contiguously and
|
||||
// concurrent WriteAt calls never race to extend it.
|
||||
if err := f.Truncate(total); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Publish progress + a smoothed speed once a second.
|
||||
// Publication de la progression + vitesse lissée, une fois par seconde.
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(time.Second)
|
||||
@@ -514,7 +536,7 @@ func runDownload(ctx context.Context, st *dlState, dlURL, dest string) {
|
||||
if st.Speed == 0 {
|
||||
st.Speed = inst
|
||||
} else {
|
||||
st.Speed = (st.Speed*2 + inst) / 3 // EMA, smooths CDN jitter
|
||||
st.Speed = (st.Speed*2 + inst) / 3 // EMA, lisse les à-coups du CDN
|
||||
}
|
||||
}
|
||||
dlMu.Unlock()
|
||||
@@ -523,23 +545,74 @@ func runDownload(ctx context.Context, st *dlState, dlURL, dest string) {
|
||||
}
|
||||
}()
|
||||
|
||||
err = dlFetch(ctx, f, dlURL, total, conns, &done)
|
||||
var written []string // tranches menées à bien par CE téléchargement
|
||||
for i := range urls {
|
||||
dlMu.Lock()
|
||||
st.Part = i + 1
|
||||
dlMu.Unlock()
|
||||
if err := dlOnePart(ctx, st, urls[i], dests[i], totals[i], rangeds[i], &done); err != nil {
|
||||
close(stop)
|
||||
for _, p := range written {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
finish(err) // annulation comprise : le .part est supprimé dans tous les cas
|
||||
return
|
||||
}
|
||||
written = append(written, dests[i])
|
||||
}
|
||||
close(stop)
|
||||
finish(nil)
|
||||
}
|
||||
|
||||
// dlOnePart télécharge UN fichier dans un .part puis le renomme au succès.
|
||||
// Quand la source honore les plages d'octets (le CDN de Hugging Face le fait),
|
||||
// le fichier est réparti sur plusieurs connexions écrites en place via WriteAt :
|
||||
// c'est ce qui fait qu'un .gguf de plusieurs Go sature le lien au lieu de se
|
||||
// traîner sur un seul flux TCP. done est incrémenté globalement (il compte pour
|
||||
// tout le modèle, pas seulement pour cette tranche).
|
||||
func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64, ranged bool, done *int64) error {
|
||||
tmp := dest + ".part"
|
||||
conns := 1
|
||||
if ranged && total > 0 {
|
||||
conns = dlConns()
|
||||
if max := int((total + dlMinChunk - 1) / dlMinChunk); conns > max {
|
||||
conns = max
|
||||
}
|
||||
if conns < 1 {
|
||||
conns = 1
|
||||
}
|
||||
}
|
||||
dlMu.Lock()
|
||||
st.Conns = conns
|
||||
dlMu.Unlock()
|
||||
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
fail(err) // includes cancellation: the .part is deleted either way
|
||||
return
|
||||
return err
|
||||
}
|
||||
if conns > 1 {
|
||||
// Préallocation : le système de fichiers pose le fichier d'un bloc et les
|
||||
// WriteAt concurrents n'ont jamais à l'étendre en même temps.
|
||||
if err := f.Truncate(total); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
finish(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
finish(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
finish(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dlFetch writes the whole body into f, either as one stream or as `conns`
|
||||
|
||||
@@ -52,14 +52,14 @@ func TestRunDownloadCancelLeavesNothing(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
st := &dlState{Filename: "m.gguf", cancel: cancel}
|
||||
done := make(chan struct{})
|
||||
go func() { runDownload(ctx, st, srv.URL+"/m.gguf", dest); close(done) }()
|
||||
go func() { runDownloadSet(ctx, st, []string{srv.URL + "/m.gguf"}, []string{dest}); close(done) }()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("runDownload n'a pas rendu la main après annulation")
|
||||
t.Fatal("runDownloadSet n a pas rendu la main apres annulation")
|
||||
}
|
||||
|
||||
if !st.Canceled || !st.Finished {
|
||||
@@ -106,7 +106,7 @@ func TestRunDownloadParallelAndFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "m.gguf")
|
||||
st := &dlState{Filename: "m.gguf"}
|
||||
runDownload(context.Background(), st, srv.URL+"/m.gguf", dest)
|
||||
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
|
||||
srv.Close()
|
||||
|
||||
if st.Err != "" {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Modèles découpés en plusieurs fichiers. Au-delà d'une cinquantaine de Go, un
|
||||
// dépôt Hugging Face publie son GGUF en tranches nommées par convention
|
||||
// llama.cpp : `<base>-00001-of-00003.gguf`, `…-00002-of-00003.gguf`, etc.
|
||||
// llama-server n'a besoin QUE de la première : il ouvre les suivantes tout seul,
|
||||
// à condition qu'elles soient dans le même dossier.
|
||||
//
|
||||
// Tout le reste d'AJEAN traitait chaque tranche comme un modèle indépendant :
|
||||
// le lien collé ne rapatriait qu'un fichier sur trois (le moteur mourait sur
|
||||
// « tensor not found »), la liste du sélecteur montrait trois entrées dont deux
|
||||
// ne démarrent pas, et supprimer « le modèle » laissait les deux autres tranches
|
||||
// occuper le disque. Ce fichier ramène la tranche au rang de détail : une
|
||||
// famille de tranches = UN modèle, désigné par sa première.
|
||||
|
||||
// shardRe reconnaît le suffixe de tranche en fin de nom de fichier. Le nombre de
|
||||
// chiffres n'est pas figé à 5 : le convertisseur de llama.cpp en pose 5, mais
|
||||
// des dépôts en publient avec moins.
|
||||
var shardRe = regexp.MustCompile(`(?i)-(\d{2,5})-of-(\d{2,5})\.gguf$`)
|
||||
|
||||
// shardInfo décompose un nom de tranche : rang (1-based) et nombre total. ok
|
||||
// vaut false pour un modèle d'un seul fichier.
|
||||
func shardInfo(name string) (idx, total int, ok bool) {
|
||||
m := shardRe.FindStringSubmatch(baseName(name))
|
||||
if m == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
idx, _ = strconv.Atoi(m[1])
|
||||
total, _ = strconv.Atoi(m[2])
|
||||
if idx < 1 || total < 1 || idx > total {
|
||||
return 0, 0, false // numérotation incohérente : on le traite en fichier simple
|
||||
}
|
||||
return idx, total, true
|
||||
}
|
||||
|
||||
// shardNameAt renvoie le nom de la tranche de rang i dans la même famille, en
|
||||
// conservant la largeur de la numérotation d'origine (00002, pas 2).
|
||||
func shardNameAt(name string, i int) string {
|
||||
m := shardRe.FindStringSubmatchIndex(name)
|
||||
if m == nil {
|
||||
return name
|
||||
}
|
||||
width := m[3] - m[2]
|
||||
totalPart := name[m[4]:m[5]]
|
||||
return name[:m[2]] + fmt.Sprintf("%0*d", width, i) + "-of-" + totalPart + name[m[5]:]
|
||||
}
|
||||
|
||||
// isFollowerShard : tranche 2..N, celles qu'on ne montre jamais comme un modèle
|
||||
// à part entière (elles ne démarrent pas seules).
|
||||
func isFollowerShard(name string) bool {
|
||||
idx, _, ok := shardInfo(name)
|
||||
return ok && idx > 1
|
||||
}
|
||||
|
||||
// shardFamily renvoie les noms de TOUTES les tranches de la famille de name (y
|
||||
// compris name), dans l'ordre. Un fichier simple se renvoie lui-même.
|
||||
func shardFamily(name string) []string {
|
||||
base := baseName(name)
|
||||
_, total, ok := shardInfo(base)
|
||||
if !ok {
|
||||
return []string{base}
|
||||
}
|
||||
out := make([]string, 0, total)
|
||||
for i := 1; i <= total; i++ {
|
||||
out = append(out, shardNameAt(base, i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shardFamilySize additionne la taille des tranches PRÉSENTES dans dir. Sert à
|
||||
// afficher « 42 Go » sur la première tranche au lieu des 15 Go qu'elle pèse
|
||||
// seule, ce qui laissait croire à un modèle trois fois plus petit.
|
||||
func shardFamilySize(dir, name string) int64 {
|
||||
var sum int64
|
||||
for _, n := range shardFamily(name) {
|
||||
if st, err := os.Stat(filepath.Join(dir, n)); err == nil && !st.IsDir() {
|
||||
sum += st.Size()
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// shardFamilyMissing liste les tranches ABSENTES du dossier. Un modèle dont il
|
||||
// manque une tranche démarre puis meurt sur une erreur de tenseur introuvable :
|
||||
// autant le dire dans la liste plutôt que dans les logs du moteur.
|
||||
func shardFamilyMissing(dir, name string) []string {
|
||||
var missing []string
|
||||
for _, n := range shardFamily(name) {
|
||||
if st, err := os.Stat(filepath.Join(dir, n)); err != nil || st.IsDir() {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// shardURLSet transforme l'URL d'UNE tranche en la liste complète des URLs de la
|
||||
// famille, dans l'ordre, avec les noms de fichiers correspondants. Le lien collé
|
||||
// peut désigner n'importe quelle tranche (on tombe souvent sur la deuxième en
|
||||
// parcourant l'arborescence du dépôt) : on remonte toujours à la première.
|
||||
func shardURLSet(dlURL, name string) (urls, names []string) {
|
||||
fam := shardFamily(name)
|
||||
if len(fam) == 1 {
|
||||
return []string{dlURL}, fam
|
||||
}
|
||||
base := baseName(name)
|
||||
for _, n := range fam {
|
||||
// Remplacement du seul nom de fichier, en fin d'URL : le nom peut réapparaître
|
||||
// ailleurs dans le chemin (dossier de quantification portant le même libellé),
|
||||
// et un ReplaceAll y toucherait aussi.
|
||||
i := strings.LastIndex(dlURL, base)
|
||||
if i < 0 {
|
||||
return []string{dlURL}, []string{base} // lien inattendu : on ne devine rien
|
||||
}
|
||||
urls = append(urls, dlURL[:i]+n+dlURL[i+len(base):])
|
||||
names = append(names, n)
|
||||
}
|
||||
return urls, names
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShardInfo(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
idx, total int
|
||||
ok bool
|
||||
}{
|
||||
{"Laguna-S-2.1-UD-IQ4_XS-00001-of-00003.gguf", 1, 3, true},
|
||||
{"Laguna-S-2.1-UD-IQ4_XS-00002-of-00003.gguf", 2, 3, true},
|
||||
{"m-01-of-03.gguf", 1, 3, true},
|
||||
{"Qwen2.5-7B-Instruct-Q4_K_M.gguf", 0, 0, false},
|
||||
{"bidon-00004-of-00003.gguf", 0, 0, false}, // numérotation incohérente
|
||||
{"pas-un-gguf-00001-of-00002.bin", 0, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
idx, total, ok := shardInfo(c.name)
|
||||
if idx != c.idx || total != c.total || ok != c.ok {
|
||||
t.Errorf("shardInfo(%q) = %d,%d,%v ; attendu %d,%d,%v", c.name, idx, total, ok, c.idx, c.total, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShardFamilyGardeLaLargeur(t *testing.T) {
|
||||
got := shardFamily("Laguna-00002-of-00003.gguf")
|
||||
want := []string{
|
||||
"Laguna-00001-of-00003.gguf",
|
||||
"Laguna-00002-of-00003.gguf",
|
||||
"Laguna-00003-of-00003.gguf",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("famille = %v ; attendu %v", got, want)
|
||||
}
|
||||
if solo := shardFamily("simple.gguf"); !reflect.DeepEqual(solo, []string{"simple.gguf"}) {
|
||||
t.Fatalf("fichier simple = %v", solo)
|
||||
}
|
||||
}
|
||||
|
||||
// Le lien collé peut désigner n'importe quelle tranche : on remonte toujours à la
|
||||
// première, et on ne réécrit QUE le nom de fichier final (le même libellé peut
|
||||
// apparaître plus haut dans le chemin du dépôt).
|
||||
func TestShardURLSet(t *testing.T) {
|
||||
base := "https://huggingface.co/unsloth/Laguna-S-2.1-GGUF/resolve/main/UD-IQ4_XS/"
|
||||
in := base + "Laguna-UD-IQ4_XS-00002-of-00003.gguf"
|
||||
urls, names := shardURLSet(in, "Laguna-UD-IQ4_XS-00002-of-00003.gguf")
|
||||
if len(urls) != 3 || len(names) != 3 {
|
||||
t.Fatalf("attendu 3 tranches, got %d/%d", len(urls), len(names))
|
||||
}
|
||||
if names[0] != "Laguna-UD-IQ4_XS-00001-of-00003.gguf" {
|
||||
t.Fatalf("première tranche = %q", names[0])
|
||||
}
|
||||
if urls[0] != base+"Laguna-UD-IQ4_XS-00001-of-00003.gguf" {
|
||||
t.Fatalf("URL de la première tranche = %q", urls[0])
|
||||
}
|
||||
if urls[2] != base+"Laguna-UD-IQ4_XS-00003-of-00003.gguf" {
|
||||
t.Fatalf("URL de la dernière tranche = %q", urls[2])
|
||||
}
|
||||
// Fichier simple : une seule URL, inchangée.
|
||||
u, n := shardURLSet(base+"m.gguf", "m.gguf")
|
||||
if len(u) != 1 || u[0] != base+"m.gguf" || n[0] != "m.gguf" {
|
||||
t.Fatalf("fichier simple mal traité : %v %v", u, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShardFamilyTailleEtManquantes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(n string, size int) {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), make([]byte, size), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("m-00001-of-00003.gguf", 100)
|
||||
write("m-00003-of-00003.gguf", 300)
|
||||
|
||||
if got := shardFamilySize(dir, "m-00001-of-00003.gguf"); got != 400 {
|
||||
t.Fatalf("taille de la famille = %d ; attendu 400", got)
|
||||
}
|
||||
missing := shardFamilyMissing(dir, "m-00001-of-00003.gguf")
|
||||
if !reflect.DeepEqual(missing, []string{"m-00002-of-00003.gguf"}) {
|
||||
t.Fatalf("tranches manquantes = %v", missing)
|
||||
}
|
||||
write("m-00002-of-00003.gguf", 200)
|
||||
if m := shardFamilyMissing(dir, "m-00001-of-00003.gguf"); len(m) != 0 {
|
||||
t.Fatalf("famille complète signalée incomplète : %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer un modèle découpé doit emporter TOUTES ses tranches, sinon des
|
||||
// dizaines de Go restent sur le disque sans que rien ne sache plus les effacer.
|
||||
func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
|
||||
testHome(t)
|
||||
dir := modelsDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{"m-00001-of-00002.gguf", "m-00002-of-00002.gguf", "autre.gguf"}
|
||||
for _, n := range names {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range names[:2] {
|
||||
if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
|
||||
t.Fatalf("tranche %s non supprimée", n)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "autre.gguf")); err != nil {
|
||||
t.Fatal("un modèle sans rapport a été supprimé")
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,13 @@ func safePresetPath(name string) (string, error) {
|
||||
// paramètres de modèle) qui doivent survivre à un changement de preset. Sans ça,
|
||||
// écraser config.env avec le preset ré-imposerait le mode mémoire et effacerait
|
||||
// l'URL du serveur internet à chaque bascule — ce qui obligeait à tout remettre.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES"}
|
||||
// HOST y figure depuis la 0.8.4 : c'est un réglage de MACHINE (« le moteur
|
||||
// est-il joignable depuis le réseau ? », voir sys_network.go), pas un réglage de
|
||||
// modèle. Sans lui dans cette liste, basculer sur un preset écrit avant cette
|
||||
// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
|
||||
// interfaces » : une machine volontairement fermée se rouvrait toute seule au
|
||||
// premier changement de preset.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"}
|
||||
|
||||
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
|
||||
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
|
||||
@@ -193,7 +199,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
|
||||
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
|
||||
// modèle se retrouvait entièrement sur une seule carte et mourait sur
|
||||
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true}
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true}
|
||||
|
||||
// applyPresetFile installe le preset comme configuration active, en réinjectant
|
||||
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
|
||||
|
||||
@@ -61,6 +61,13 @@ func cmdServe(args []string) error {
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
return fmt.Errorf("modèle introuvable : %s", model)
|
||||
}
|
||||
// Modèle découpé en tranches : llama-server ouvre les suivantes tout seul, mais
|
||||
// s'il en manque une il démarre puis meurt sur un tenseur introuvable — message
|
||||
// incompréhensible, et systemd relance en boucle. On le dit ici, en clair.
|
||||
if missing := shardFamilyMissing(filepath.Dir(model), filepath.Base(model)); len(missing) > 0 {
|
||||
return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous",
|
||||
strings.Join(missing, ", "), filepath.Dir(model), len(shardFamily(filepath.Base(model))))
|
||||
}
|
||||
if !filepath.IsAbs(bin) {
|
||||
bin = filepath.Join(AjeanHome(), bin)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chat_export.go — sortir la conversation de la base.
|
||||
//
|
||||
// Jusqu'à la 0.7, l'historique vivait dans un `conversation.json` qu'on pouvait
|
||||
// ouvrir, lire et copier pour archive. La 0.8 l'a rangé dans ajean.db (bbolt) :
|
||||
// un fichier binaire, et qui porte de surcroît un verrou EXCLUSIF tant que le
|
||||
// service tourne — donc impossible ne serait-ce que de le copier sans arrêter
|
||||
// AJEAN. Le fil de discussion et les raisonnements sont toujours là, mais plus
|
||||
// personne ne peut les récupérer. C'est une régression pour qui relisait ses
|
||||
// échanges, et ce fichier la répare.
|
||||
//
|
||||
// Deux formats, un même contenu : le JSON est fidèle (vue modèle + journal
|
||||
// d'affichage complet, rejouable), le Markdown est fait pour être lu.
|
||||
|
||||
// exportPayload est la forme du fichier JSON exporté. `messages` est la vue
|
||||
// envoyée au modèle, `log` le journal d'affichage (celui qui porte les
|
||||
// raisonnements, les outils et les vitesses).
|
||||
type exportPayload struct {
|
||||
Version string `json:"ajean_version"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
CtxUsed int `json:"ctx_used"`
|
||||
Messages []Message `json:"messages"`
|
||||
Log []LogEvent `json:"log"`
|
||||
}
|
||||
|
||||
// ExportJSON rend la conversation entière en JSON indenté.
|
||||
func (c *Conversation) ExportJSON() ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
p := exportPayload{
|
||||
Version: Version,
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
CtxUsed: c.CtxUsed,
|
||||
Messages: append([]Message(nil), c.Messages...),
|
||||
Log: append([]LogEvent(nil), c.Log...),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return json.MarshalIndent(p, "", " ")
|
||||
}
|
||||
|
||||
// ExportMarkdown rend la conversation en Markdown lisible : un bloc par tour,
|
||||
// le raisonnement replié dans un <details> (il est souvent plus long que la
|
||||
// réponse), et les outils appelés en liste.
|
||||
//
|
||||
// On repart du JOURNAL D'AFFICHAGE et non de la vue modèle : c'est lui qui porte
|
||||
// les raisonnements et la trace des outils, précisément ce qu'on vient chercher
|
||||
// dans un export. coalesceReplay est réutilisé tel quel pour recoller les
|
||||
// milliers de deltas d'un tour en un bloc de texte par bulle.
|
||||
func (c *Conversation) ExportMarkdown() string {
|
||||
c.mu.Lock()
|
||||
snapshot := append([]LogEvent(nil), c.Log...)
|
||||
c.mu.Unlock()
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Conversation AJEAN\n\nExportée le %s par AJEAN %s.\n",
|
||||
time.Now().Format("02/01/2006 à 15:04"), Version)
|
||||
|
||||
// openBubble : une bulle assistant est ouverte (au moins un bout de réponse a
|
||||
// été écrit). Sert à ne poser l'en-tête « AJEAN » qu'une fois par tour, même
|
||||
// quand la réponse est entrecoupée d'appels d'outils.
|
||||
openBubble := false
|
||||
for _, ev := range coalesceReplay(snapshot, 0) {
|
||||
switch {
|
||||
case ev["user"] != nil:
|
||||
openBubble = false
|
||||
fmt.Fprintf(&b, "\n---\n\n## Vous\n\n%s\n", mdText(ev["user"]))
|
||||
case ev["reasoning_content"] != nil:
|
||||
if !openBubble {
|
||||
b.WriteString("\n## AJEAN\n")
|
||||
openBubble = true
|
||||
}
|
||||
fmt.Fprintf(&b, "\n<details>\n<summary>Raisonnement</summary>\n\n%s\n\n</details>\n",
|
||||
mdText(ev["reasoning_content"]))
|
||||
case ev["content"] != nil:
|
||||
if !openBubble {
|
||||
b.WriteString("\n## AJEAN\n")
|
||||
openBubble = true
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%s\n", mdText(ev["content"]))
|
||||
case ev["tool_used"] != nil:
|
||||
tu, _ := ev["tool_used"].(map[string]any)
|
||||
if tu == nil {
|
||||
break
|
||||
}
|
||||
if !openBubble {
|
||||
b.WriteString("\n## AJEAN\n")
|
||||
openBubble = true
|
||||
}
|
||||
label, _ := tu["label"].(string)
|
||||
name, _ := tu["name"].(string)
|
||||
if label == "" {
|
||||
label = name
|
||||
}
|
||||
fmt.Fprintf(&b, "\n> 🔧 **%s** — %s\n", mdInline(name), mdInline(label))
|
||||
if res, _ := tu["result"].(string); strings.TrimSpace(res) != "" {
|
||||
fmt.Fprintf(&b, "\n```\n%s\n```\n", strings.TrimRight(res, "\n"))
|
||||
}
|
||||
case ev["compacted"] != nil:
|
||||
fmt.Fprintf(&b, "\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n")
|
||||
}
|
||||
}
|
||||
if !openBubble && len(snapshot) == 0 {
|
||||
b.WriteString("\n_Conversation vide._\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mdText rend une valeur d'événement en texte de bloc Markdown.
|
||||
func mdText(v any) string {
|
||||
s, _ := v.(string)
|
||||
return strings.TrimRight(s, "\n")
|
||||
}
|
||||
|
||||
// mdInline neutralise ce qui casserait une ligne Markdown (un label d'outil peut
|
||||
// contenir un chemin, des astérisques, un retour à la ligne).
|
||||
func mdInline(v any) string {
|
||||
s, _ := v.(string)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
r := strings.NewReplacer("*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]")
|
||||
return strings.TrimSpace(r.Replace(s))
|
||||
}
|
||||
|
||||
// exportFilename : nom proposé au téléchargement, horodaté pour que deux exports
|
||||
// ne s'écrasent pas dans le dossier de téléchargements.
|
||||
func exportFilename(ext string) string {
|
||||
return "ajean-conversation-" + time.Now().Format("2006-01-02-1504") + "." + ext
|
||||
}
|
||||
|
||||
// cmdExport écrit la conversation dans un fichier (ou sur la sortie standard).
|
||||
//
|
||||
// ajean export → ajean-conversation-<date>.md dans le dossier courant
|
||||
// ajean export --json → idem en JSON
|
||||
// ajean export mon-fil.md → nom de fichier imposé (l'extension choisit le format)
|
||||
// ajean export - → sur la sortie standard, pour enchaîner un tube
|
||||
//
|
||||
// La conversation est relue depuis la base à chaque appel : la commande marche
|
||||
// pendant que le service tourne (bbolt n'est jamais gardé ouvert, voir store.go).
|
||||
func cmdExport(args []string) error {
|
||||
format, out := "md", ""
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--json", "-j":
|
||||
format = "json"
|
||||
case "--md", "--markdown", "-m":
|
||||
format = "md"
|
||||
default:
|
||||
if strings.HasPrefix(a, "-") && a != "-" {
|
||||
return fmt.Errorf("option inconnue : %s (attendu --json ou --md)", a)
|
||||
}
|
||||
out = a
|
||||
}
|
||||
}
|
||||
// Une extension explicite l'emporte sur le drapeau : `ajean export fil.json`
|
||||
// qui produirait du Markdown serait un piège.
|
||||
if strings.HasSuffix(strings.ToLower(out), ".json") {
|
||||
format = "json"
|
||||
} else if strings.HasSuffix(strings.ToLower(out), ".md") {
|
||||
format = "md"
|
||||
}
|
||||
|
||||
LoadConversation()
|
||||
var body []byte
|
||||
if format == "json" {
|
||||
b, err := conv.ExportJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = b
|
||||
} else {
|
||||
body = []byte(conv.ExportMarkdown())
|
||||
}
|
||||
|
||||
if out == "-" {
|
||||
_, err := os.Stdout.Write(body)
|
||||
return err
|
||||
}
|
||||
if out == "" {
|
||||
out = exportFilename(format)
|
||||
}
|
||||
if err := os.WriteFile(out, body, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
fmt.Printf("%s %s (%d messages, %s)\n", green("[ok]"), out, n, humanBytes(int64(len(body))))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleChatExport sert la conversation en téléchargement. `format=md` (défaut)
|
||||
// ou `format=json`.
|
||||
func handleChatExport(w http.ResponseWriter, r *http.Request) {
|
||||
var body []byte
|
||||
var ctype, ext string
|
||||
if r.URL.Query().Get("format") == "json" {
|
||||
b, err := conv.ExportJSON()
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
body, ctype, ext = b, "application/json; charset=utf-8", "json"
|
||||
} else {
|
||||
body, ctype, ext = []byte(conv.ExportMarkdown()), "text/markdown; charset=utf-8", "md"
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+exportFilename(ext)+`"`)
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// convDeTest pose un fil complet (question, raisonnement, outil, réponse) dans la
|
||||
// conversation globale et la remet à zéro en fin de test.
|
||||
func convDeTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { conv.Reset() })
|
||||
conv.mu.Lock()
|
||||
conv.Messages = []Message{
|
||||
{Role: "user", Content: "combien font 2+2 ?"},
|
||||
{Role: "assistant", Content: "4"},
|
||||
}
|
||||
conv.Log = []LogEvent{
|
||||
{Seq: 1, TS: 1, Delta: map[string]any{"user": "combien font 2+2 ?"}},
|
||||
{Seq: 2, TS: 2, Delta: map[string]any{"reasoning_content": "addition simple"}},
|
||||
{Seq: 3, TS: 3, Delta: map[string]any{"tool_used": map[string]any{
|
||||
"name": "bash", "label": "echo 4", "result": "4", "done": true}}},
|
||||
{Seq: 4, TS: 4, Delta: map[string]any{"content": "4"}},
|
||||
{Seq: 5, TS: 5, Delta: map[string]any{"turn_done": true}},
|
||||
}
|
||||
conv.Seq = 5
|
||||
conv.CtxUsed = 123
|
||||
conv.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestExportMarkdownPorteToutLeFil(t *testing.T) {
|
||||
convDeTest(t)
|
||||
md := conv.ExportMarkdown()
|
||||
for _, want := range []string{
|
||||
"## Vous", "combien font 2+2 ?",
|
||||
"## AJEAN",
|
||||
"<summary>Raisonnement</summary>", "addition simple",
|
||||
"bash", "echo 4",
|
||||
} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("Markdown exporté sans %q :\n%s", want, md)
|
||||
}
|
||||
}
|
||||
// L'en-tête AJEAN ne doit apparaître qu'UNE fois pour un tour, même entrecoupé
|
||||
// d'un appel d'outil.
|
||||
if n := strings.Count(md, "\n## AJEAN\n"); n != 1 {
|
||||
t.Errorf("en-tête assistant répété %d fois", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportJSONRelisible(t *testing.T) {
|
||||
convDeTest(t)
|
||||
b, err := conv.ExportJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var p exportPayload
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Messages) != 2 || len(p.Log) != 5 {
|
||||
t.Fatalf("export tronqué : %d messages, %d événements", len(p.Messages), len(p.Log))
|
||||
}
|
||||
if p.CtxUsed != 123 || p.Version != Version {
|
||||
t.Fatalf("métadonnées absentes : ctx=%d version=%q", p.CtxUsed, p.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// L'endpoint doit se présenter en TÉLÉCHARGEMENT (et pas s'afficher dans
|
||||
// l'onglet), avec un nom de fichier horodaté.
|
||||
func TestHandleChatExportEnPieceJointe(t *testing.T) {
|
||||
convDeTest(t)
|
||||
for _, tc := range []struct{ format, ctype, ext string }{
|
||||
{"", "text/markdown", ".md"},
|
||||
{"json", "application/json", ".json"},
|
||||
} {
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?format="+tc.format, nil))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("format=%q : HTTP %d", tc.format, rr.Code)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) {
|
||||
t.Errorf("format=%q : Content-Type %q", tc.format, ct)
|
||||
}
|
||||
cd := rr.Header().Get("Content-Disposition")
|
||||
if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, tc.ext) {
|
||||
t.Errorf("format=%q : Content-Disposition %q", tc.format, cd)
|
||||
}
|
||||
if rr.Body.Len() == 0 {
|
||||
t.Errorf("format=%q : corps vide", tc.format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportEcritLeFichier(t *testing.T) {
|
||||
testHome(t)
|
||||
convDeTest(t)
|
||||
// LoadConversation (appelé par cmdExport) relit la base : on y persiste d'abord
|
||||
// le fil, sinon la commande exporterait une conversation vide.
|
||||
conv.persist()
|
||||
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"fil.md", "fil.json"} {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := cmdExport([]string{p}); err != nil {
|
||||
t.Fatalf("%s : %v", name, err)
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), "2+2") {
|
||||
t.Fatalf("%s ne contient pas la conversation :\n%s", name, b)
|
||||
}
|
||||
}
|
||||
// L'extension impose le format, même sans drapeau.
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "fil.json"))
|
||||
if !json.Valid(b) {
|
||||
t.Fatal("fil.json n'est pas du JSON valide")
|
||||
}
|
||||
if err := cmdExport([]string{"--zzz"}); err == nil {
|
||||
t.Fatal("option inconnue acceptée")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.8.3"
|
||||
const Version = "0.8.4"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
|
||||
func Main() {
|
||||
@@ -62,10 +62,14 @@ func Main() {
|
||||
mustExit(showVram())
|
||||
case "gpu":
|
||||
mustExit(cmdGPU(args))
|
||||
case "network":
|
||||
mustExit(cmdNetwork(args))
|
||||
case "switch":
|
||||
mustExit(cmdSwitch(args))
|
||||
case "chat":
|
||||
mustExit(cmdChat(args))
|
||||
case "export":
|
||||
mustExit(cmdExport(args))
|
||||
case "web":
|
||||
mustExit(cmdWeb(args))
|
||||
case "link":
|
||||
@@ -129,6 +133,8 @@ Moteur (ajean-engine) :
|
||||
vram utilisation GPU/VRAM (nvidia-smi)
|
||||
gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous)
|
||||
set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer
|
||||
network [on|off|status] rendre l'endpoint OpenAI joignable depuis le réseau local
|
||||
(HOST + règle de pare-feu sous Windows)
|
||||
|
||||
Interface (ajean-ui) :
|
||||
ui [start|stop|restart|status] pilote le service d'interface
|
||||
@@ -138,6 +144,8 @@ Interface (ajean-ui) :
|
||||
|
||||
Interaction:
|
||||
chat [system-prompt] chat terminal streamé
|
||||
export [--json] [fichier] exporte la conversation de l'interface web
|
||||
(Markdown par défaut, « - » = sortie standard)
|
||||
agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire)
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA
|
||||
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
|
||||
|
||||
@@ -2,11 +2,15 @@ package ajean
|
||||
|
||||
import "testing"
|
||||
|
||||
// testHome donne au test un $AJEAN_HOME à lui.
|
||||
// testHome donne au test un $AJEAN_HOME à lui, et neutralise le pare-feu : un
|
||||
// test ne doit jamais poser (ni retirer) une règle entrante sur la machine qui
|
||||
// le fait tourner.
|
||||
func testHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
firewallInert = true
|
||||
t.Cleanup(func() { firewallInert = false })
|
||||
return home
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package ajean
|
||||
|
||||
// Linux et macOS n'ont pas de pare-feu entrant activé par défaut, et quand il y
|
||||
// en a un (ufw, firewalld, pf) c'est une affaire d'administration système qu'un
|
||||
// gestionnaire de modèles n'a pas à trancher dans le dos de l'utilisateur. On
|
||||
// n'en pilote donc aucun : « inconnu » dit exactement ça, et l'interface
|
||||
// n'affiche pas d'avertissement de pare-feu là où il n'y a rien à avertir.
|
||||
|
||||
// firewallInert n'a de rôle que sous Windows (voir sys_firewall_windows.go) ;
|
||||
// il existe ici pour que le code de test reste commun aux trois plateformes.
|
||||
var firewallInert bool
|
||||
|
||||
func firewallOpen(int) error { return nil }
|
||||
func firewallClose(int) error { return nil }
|
||||
|
||||
func firewallState(int) string { return "inconnu" }
|
||||
|
||||
func firewallManualHint(int) string { return "" }
|
||||
@@ -0,0 +1,85 @@
|
||||
//go:build windows
|
||||
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sys_firewall_windows.go — règles de pare-feu entrantes pour le moteur.
|
||||
//
|
||||
// Sur une installation Windows fraîche, même avec HOST=0.0.0.0, le pare-feu
|
||||
// bloque tout ce qui arrive du réseau : l'utilisateur voyait « ça écoute
|
||||
// partout » et se prenait quand même un délai d'attente depuis son autre
|
||||
// machine. AJEAN pose donc la règle lui-même, quand il en a le droit.
|
||||
//
|
||||
// `ajean install` ne réclame PAS les droits administrateur (voir
|
||||
// sys_install_windows.go) : netsh échouera donc souvent. On ne fait pas semblant
|
||||
// que ça a marché — firewallState relit la règle, et firewallManualHint donne la
|
||||
// commande exacte à coller dans un terminal administrateur.
|
||||
|
||||
// firewallRuleName identifie nos règles. Une par port : changer PORT dans la
|
||||
// configuration ne doit pas laisser une règle ouverte sur l'ancien.
|
||||
func firewallRuleName(port int) string {
|
||||
return "AJEAN moteur (port " + strconv.Itoa(port) + ")"
|
||||
}
|
||||
|
||||
// netsh exécute netsh sans faire clignoter de console (hideCmd) et renvoie sa
|
||||
// sortie combinée.
|
||||
//
|
||||
// firewallInert coupe tout appel à netsh. Positionné par les tests : une suite
|
||||
// qui laisse une règle entrante derrière elle est un effet de bord inacceptable,
|
||||
// et sur un runner élevé elle ouvrirait le port pour de bon.
|
||||
var firewallInert bool
|
||||
|
||||
func netsh(args ...string) (string, error) {
|
||||
if firewallInert {
|
||||
return "", fmt.Errorf("pare-feu non piloté")
|
||||
}
|
||||
out, err := hideCmd(exec.Command("netsh", args...)).CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
// firewallOpen autorise le port en entrée (TCP), pour les profils privé et
|
||||
// domaine seulement : ouvrir un modèle non authentifié sur un réseau public
|
||||
// (café, hôtel) n'est pas un défaut qu'on pose au nom de l'utilisateur.
|
||||
func firewallOpen(port int) error {
|
||||
_ = firewallClose(port) // idempotence : pas d'empilement de règles homonymes
|
||||
out, err := netsh("advfirewall", "firewall", "add", "rule",
|
||||
"name="+firewallRuleName(port), "dir=in", "action=allow",
|
||||
"protocol=TCP", "localport="+strconv.Itoa(port), "profile=private,domain")
|
||||
if err != nil {
|
||||
return fmt.Errorf("règle de pare-feu refusée (droits administrateur requis) : %s", out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firewallClose retire la règle. Absente = rien à faire, et surtout pas une
|
||||
// erreur.
|
||||
func firewallClose(port int) error {
|
||||
_, _ = netsh("advfirewall", "firewall", "delete", "rule", "name="+firewallRuleName(port))
|
||||
return nil
|
||||
}
|
||||
|
||||
// firewallState relit l'état RÉEL de la règle plutôt que de croire au succès
|
||||
// supposé d'une commande passée.
|
||||
func firewallState(port int) string {
|
||||
out, err := netsh("advfirewall", "firewall", "show", "rule", "name="+firewallRuleName(port))
|
||||
if err != nil || strings.TrimSpace(out) == "" {
|
||||
return "ferme"
|
||||
}
|
||||
// netsh répond « Aucune règle ne correspond aux critères » (localisé) avec un
|
||||
// code de sortie non nul : le err ci-dessus suffit. Ici la règle existe.
|
||||
return "ouvert"
|
||||
}
|
||||
|
||||
// firewallManualHint : la commande à coller dans un terminal ADMINISTRATEUR
|
||||
// quand AJEAN n'a pas pu poser la règle lui-même.
|
||||
func firewallManualHint(port int) string {
|
||||
return fmt.Sprintf("le pare-feu Windows bloque encore le port %d. Ouvre un terminal ADMINISTRATEUR et lance :\n"+
|
||||
` netsh advfirewall firewall add rule name="%s" dir=in action=allow protocol=TCP localport=%d profile=private,domain`,
|
||||
port, firewallRuleName(port), port)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sys_network.go — « est-ce que ma machine est joignable depuis le réseau ? »
|
||||
//
|
||||
// Le moteur écoute à l'adresse HOST. Sous Windows, l'installation pose
|
||||
// HOST=127.0.0.1 : le chat du navigateur marche (il passe par le serveur web
|
||||
// d'AJEAN, sur la même machine), mais l'endpoint OpenAI :8080/v1 est INVISIBLE
|
||||
// depuis le reste du réseau. Or c'est très exactement ce que les gens viennent
|
||||
// chercher : brancher un logiciel tiers sur le modèle qui tourne dans le salon.
|
||||
//
|
||||
// Deux choses manquaient, et pas une seule :
|
||||
// 1. HOST n'était réglable NULLE PART dans l'interface — il fallait connaître
|
||||
// « ajean edit » et savoir quoi y écrire.
|
||||
// 2. Même à 0.0.0.0, le pare-feu Windows bloque les connexions entrantes tant
|
||||
// qu'aucune règle n'autorise le port. AJEAN n'en posait aucune.
|
||||
//
|
||||
// D'où un seul interrupteur qui fait les deux, et qui DIT ce qu'il n'a pas pu
|
||||
// faire (poser une règle de pare-feu exige les droits administrateur, que
|
||||
// l'installation d'AJEAN ne réclame pas).
|
||||
|
||||
// hostLocalOnly est l'adresse d'écoute « cette machine seulement ».
|
||||
const hostLocalOnly = "127.0.0.1"
|
||||
|
||||
// hostAllInterfaces est l'adresse d'écoute « tout le réseau ».
|
||||
const hostAllInterfaces = "0.0.0.0"
|
||||
|
||||
// engineHost renvoie l'adresse d'écoute configurée pour le moteur. Vide = le
|
||||
// défaut de backend_serve.go, c'est-à-dire toutes les interfaces.
|
||||
func engineHost() string {
|
||||
h := strings.TrimSpace(ReadConfig()["HOST"])
|
||||
if h == "" {
|
||||
return hostAllInterfaces
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// lanExposed : le moteur écoute-t-il ailleurs que sur la boucle locale ?
|
||||
func lanExposed() bool {
|
||||
h := engineHost()
|
||||
return h != hostLocalOnly && h != "::1" && !strings.EqualFold(h, "localhost")
|
||||
}
|
||||
|
||||
// netStatus est ce que l'interface affiche, et ce que renvoie `ajean network`.
|
||||
type netStatus struct {
|
||||
Exposed bool `json:"exposed"` // le moteur écoute-t-il sur le réseau
|
||||
Host string `json:"host"` // adresse d'écoute effective
|
||||
Port int `json:"port"` // port du moteur
|
||||
URL string `json:"url"` // endpoint OpenAI à coller dans un logiciel tiers
|
||||
Firewall string `json:"firewall"` // "ouvert", "ferme", "inconnu" (aucun pare-feu piloté)
|
||||
Hint string `json:"hint"` // ce qu'il reste à faire à la main, ou ""
|
||||
}
|
||||
|
||||
// networkStatus assemble l'état courant.
|
||||
func networkStatus() netStatus {
|
||||
st := netStatus{Exposed: lanExposed(), Host: engineHost(), Port: LLMPort()}
|
||||
st.URL = fmt.Sprintf("http://%s:%d/v1", localIP(), st.Port)
|
||||
st.Firewall = firewallState(st.Port)
|
||||
if st.Exposed && st.Firewall == "ferme" {
|
||||
st.Hint = firewallManualHint(st.Port)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// setLANExposure écrit HOST et, à l'ouverture, tente de poser la règle de
|
||||
// pare-feu. Elle renvoie l'état obtenu ; une règle non posée n'est PAS une
|
||||
// erreur bloquante (l'utilisateur peut la poser lui-même, Hint le lui dit).
|
||||
//
|
||||
// Le service n'est pas redémarré ici : llama-server ne lit --host qu'au
|
||||
// lancement, et c'est à l'appelant de choisir le moment (l'interface propose le
|
||||
// redémarrage, la CLI le rappelle).
|
||||
func setLANExposure(on bool) (netStatus, error) {
|
||||
host := hostLocalOnly
|
||||
if on {
|
||||
host = hostAllInterfaces
|
||||
}
|
||||
if err := SetConfigKey("HOST", host); err != nil {
|
||||
return netStatus{}, err
|
||||
}
|
||||
port := LLMPort()
|
||||
if on {
|
||||
_ = firewallOpen(port)
|
||||
} else {
|
||||
_ = firewallClose(port)
|
||||
}
|
||||
return networkStatus(), nil
|
||||
}
|
||||
|
||||
// cmdNetwork : `ajean network [on|off|status]`.
|
||||
func cmdNetwork(args []string) error {
|
||||
sub := "status"
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on", "off":
|
||||
st, err := setLANExposure(sub == "on")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Exposed {
|
||||
fmt.Printf("%s le moteur écoutera sur tout le réseau (%s)\n", green("[ok]"), st.URL)
|
||||
} else {
|
||||
fmt.Printf("%s le moteur n'écoutera plus que sur cette machine\n", green("[ok]"))
|
||||
}
|
||||
printNetHint(st)
|
||||
fmt.Printf("%s redémarre le moteur pour appliquer : %s\n", dim("[info]"), bold("ajean restart"))
|
||||
return nil
|
||||
case "status", "":
|
||||
st := networkStatus()
|
||||
state := dim("cette machine seulement")
|
||||
if st.Exposed {
|
||||
state = green("tout le réseau")
|
||||
}
|
||||
fmt.Printf("%s écoute: %s (HOST=%s, PORT=%d)\n", cyan("Réseau"), state, st.Host, st.Port)
|
||||
switch st.Firewall {
|
||||
case "ouvert":
|
||||
fmt.Printf(" pare-feu : %s\n", green("port autorisé"))
|
||||
case "ferme":
|
||||
fmt.Printf(" pare-feu : %s\n", yellow("aucune règle pour ce port"))
|
||||
default:
|
||||
fmt.Printf(" pare-feu : %s\n", dim("non géré par AJEAN sur cette plateforme"))
|
||||
}
|
||||
if st.Exposed {
|
||||
fmt.Printf(" endpoint OpenAI : %s\n", bold(st.URL))
|
||||
}
|
||||
printNetHint(st)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("usage: ajean network [on|off|status]")
|
||||
}
|
||||
|
||||
func printNetHint(st netStatus) {
|
||||
if st.Hint != "" {
|
||||
fmt.Printf("%s %s\n", yellow("[attention]"), st.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNetwork : GET l'état, POST {exposed:bool} pour basculer.
|
||||
func handleNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Exposed *bool `json:"exposed"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Exposed != nil {
|
||||
st, err := setLANExposure(*req.Exposed)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "status": st})
|
||||
return
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "status": networkStatus()})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEngineHostDefautEtBascule(t *testing.T) {
|
||||
testHome(t)
|
||||
// Clé absente : c'est le défaut de backend_serve.go qui s'applique, soit toutes
|
||||
// les interfaces. Annoncer « fermé » ici serait un mensonge.
|
||||
if h := engineHost(); h != hostAllInterfaces {
|
||||
t.Fatalf("HOST absent → %q ; attendu %q", h, hostAllInterfaces)
|
||||
}
|
||||
if !lanExposed() {
|
||||
t.Fatal("HOST absent devrait compter comme exposé")
|
||||
}
|
||||
if _, err := setLANExposure(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lanExposed() || ReadConfig()["HOST"] != hostLocalOnly {
|
||||
t.Fatalf("fermeture sans effet : HOST=%q", ReadConfig()["HOST"])
|
||||
}
|
||||
if _, err := setLANExposure(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !lanExposed() || ReadConfig()["HOST"] != hostAllInterfaces {
|
||||
t.Fatalf("ouverture sans effet : HOST=%q", ReadConfig()["HOST"])
|
||||
}
|
||||
}
|
||||
|
||||
// « localhost » écrit à la main vaut boucle locale, au même titre que 127.0.0.1.
|
||||
func TestLanExposedReconnaitLocalhost(t *testing.T) {
|
||||
testHome(t)
|
||||
for _, h := range []string{"127.0.0.1", "localhost", "::1", "LOCALHOST"} {
|
||||
if err := SetConfigKey("HOST", h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lanExposed() {
|
||||
t.Errorf("HOST=%q compté comme exposé", h)
|
||||
}
|
||||
}
|
||||
for _, h := range []string{"0.0.0.0", "192.168.1.25", "::"} {
|
||||
if err := SetConfigKey("HOST", h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !lanExposed() {
|
||||
t.Errorf("HOST=%q compté comme fermé", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Une machine volontairement fermée ne doit pas se rouvrir toute seule parce
|
||||
// qu'on bascule sur un preset écrit avant que HOST existe.
|
||||
func TestHostSurvitAuChangementDePreset(t *testing.T) {
|
||||
testHome(t)
|
||||
if _, err := setLANExposure(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(t.TempDir(), "vieux.env")
|
||||
if err := os.WriteFile(p, []byte("MODEL=\"x.gguf\"\nCTX=4096\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := applyPresetFile(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ReadConfig()["HOST"] != hostLocalOnly {
|
||||
t.Fatalf("HOST perdu à la bascule : %q", ReadConfig()["HOST"])
|
||||
}
|
||||
// Mais un preset qui revendique HOST garde le dernier mot.
|
||||
if err := os.WriteFile(p, []byte("MODEL=\"x.gguf\"\nHOST=\"0.0.0.0\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := applyPresetFile(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ReadConfig()["HOST"] != hostAllInterfaces {
|
||||
t.Fatalf("le preset n'a pas pu imposer HOST : %q", ReadConfig()["HOST"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNetwork(t *testing.T) {
|
||||
testHome(t)
|
||||
rr := httptest.NewRecorder()
|
||||
handleNetwork(rr, httptest.NewRequest("POST", "/api/network", strings.NewReader(`{"exposed":false}`)))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("HTTP %d : %s", rr.Code, rr.Body)
|
||||
}
|
||||
var out struct {
|
||||
OK bool `json:"ok"`
|
||||
Status netStatus `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.OK || out.Status.Exposed || out.Status.Host != hostLocalOnly {
|
||||
t.Fatalf("état inattendu : %+v", out.Status)
|
||||
}
|
||||
if out.Status.Port == 0 || !strings.Contains(out.Status.URL, "/v1") {
|
||||
t.Fatalf("endpoint mal formé : %+v", out.Status)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,12 @@ func preflightEngine() error {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("ajean edit"))
|
||||
}
|
||||
// Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au moment
|
||||
// où llama-server réclame un tenseur absent, dans le journal du service.
|
||||
if missing := shardFamilyMissing(filepath.Dir(p), filepath.Base(p)); len(missing) > 0 {
|
||||
return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous",
|
||||
strings.Join(missing, ", "), filepath.Dir(p), len(shardFamily(filepath.Base(p))))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1233,6 +1233,12 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- L'adresse ci-dessus ne sert à RIEN si le moteur n'écoute que sur la
|
||||
boucle locale (le défaut sous Windows) : le chat du navigateur marche,
|
||||
mais aucun logiciel du réseau ne peut s'y brancher. Cet interrupteur
|
||||
règle HOST et pose la règle de pare-feu, voir sys_network.go. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="lan-toggle" onchange="toggleLAN()"><span class="slider"></span></span> joignable depuis le réseau local<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Éteint, le moteur n'écoute que sur <b>cette machine</b> : l'adresse ci-dessus ne répond qu'ici. Allumez-le pour brancher un logiciel depuis un autre ordinateur du réseau (sous Windows, la règle de pare-feu est posée en même temps). <b>Redémarre le moteur.</b></span></span></label>
|
||||
<div id="lan-warn" class="rowbody" style="display:none"></div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend votre IA joignable depuis <b>n'importe où</b> via <code>https://<machine>.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite l'accès distant actif.</span></span></label>
|
||||
<div id="oai-public-wrap" class="rowbody" style="display:none">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse publique — chiffrée de bout en bout, accessible partout</div>
|
||||
@@ -1340,6 +1346,14 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<button onclick="resetChat()">⌫ clear chat</button>
|
||||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||||
</div>
|
||||
<!-- Export du fil. Depuis la 0.8 la conversation vit dans ajean.db (bbolt,
|
||||
binaire et verrouillé tant que le service tourne) : sans ce bouton,
|
||||
plus personne ne peut relire ni archiver ses échanges, ce que
|
||||
permettait l'ancien conversation.json. -->
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button onclick="exportChat('md')" title="conversation complète, raisonnements compris">⤓ exporter (.md)</button>
|
||||
<button onclick="exportChat('json')" title="format fidèle : vue modèle + journal d'affichage">.json</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -2420,6 +2434,75 @@ function renderApiKey(d){
|
||||
document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none';
|
||||
}
|
||||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||||
// --- Export de la conversation ---------------------------------------------
|
||||
// On passe par jfetch (et pas par un simple <a href>) pour deux raisons : la clé
|
||||
// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas,
|
||||
// et le chemin de base change derrière le tunnel (/u/<id>).
|
||||
async function exportChat(format){
|
||||
toast('préparation de l\'export…');
|
||||
try{
|
||||
const r = await jfetch('/api/chat/export?format=' + encodeURIComponent(format||'md'));
|
||||
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
|
||||
const blob = await r.blob();
|
||||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom local.
|
||||
let name = 'ajean-conversation.' + (format==='json' ? 'json' : 'md');
|
||||
const cd = r.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
if(m) name = m[1];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
|
||||
// dans la foulée du clic.
|
||||
setTimeout(()=>URL.revokeObjectURL(url), 10000);
|
||||
toast('exporté : ' + name);
|
||||
}catch(e){ toast('erreur : ' + e.message); }
|
||||
}
|
||||
// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------
|
||||
// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le
|
||||
// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le
|
||||
// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part.
|
||||
function renderNetwork(st){
|
||||
if(!st) return;
|
||||
const cb = document.getElementById('lan-toggle');
|
||||
if(cb) cb.checked = !!st.exposed;
|
||||
const warn = document.getElementById('lan-warn');
|
||||
if(!warn) return;
|
||||
if(!st.exposed){
|
||||
warn.style.display = '';
|
||||
warn.innerHTML = '<div class="muted" style="margin:0">Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.</div>';
|
||||
return;
|
||||
}
|
||||
// Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute
|
||||
// partout » et pourtant rien ne passe). On donne la commande à coller.
|
||||
if(st.hint){
|
||||
warn.style.display = '';
|
||||
// --err et non --warn : la palette est volontairement monochrome et --warn y
|
||||
// est un gris, illisible comme alerte. Ici il y a une vraie action à faire.
|
||||
warn.innerHTML = '<div style="margin:0;color:var(--err)">⚠ '
|
||||
+ escHtml(st.hint).replace(/\n/g,'<br>') + '</div>';
|
||||
return;
|
||||
}
|
||||
warn.style.display = 'none';
|
||||
}
|
||||
async function loadNetwork(){
|
||||
try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){}
|
||||
}
|
||||
async function toggleLAN(){
|
||||
const cb = document.getElementById('lan-toggle');
|
||||
const on = cb.checked;
|
||||
const r = await jpost('/api/network', {exposed:on});
|
||||
if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; }
|
||||
renderNetwork(r.status);
|
||||
// llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur
|
||||
// affiche un état que le moteur en cours ne respecte pas encore.
|
||||
if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).',
|
||||
{title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){
|
||||
await act('restart'); // même chemin que les boutons du panneau Moteur
|
||||
}
|
||||
loadApiKey();
|
||||
}
|
||||
function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; }
|
||||
async function apiKeyAction(action){
|
||||
if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return;
|
||||
@@ -2486,7 +2569,7 @@ async function loadAll(){
|
||||
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
|
||||
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||
// hauteurs réservées en place pour toujours.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -2715,7 +2798,14 @@ async function populateModelPicker(){
|
||||
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
|
||||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
|
||||
if(on) matched = true;
|
||||
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
|
||||
// (les autres ne se lancent pas seules) et la taille de la famille entière.
|
||||
// On le dit, et surtout on signale les tranches manquantes — sinon le
|
||||
// modèle se sélectionne sans broncher puis le moteur meurt au démarrage.
|
||||
let tag = '';
|
||||
if(m.shards > 1) tag = ' · ' + m.shards + ' fichiers';
|
||||
if(m.missing && m.missing.length) tag += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
|
||||
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+tag+')</option>';
|
||||
}
|
||||
html += groups.length > 1
|
||||
? '<optgroup label="'+escHtml(g.home ? 'dossier ajean' : g.dir)+'">'+opts+'</optgroup>'
|
||||
@@ -3329,7 +3419,10 @@ async function startDownload(){
|
||||
await populateDlDirs();
|
||||
return;
|
||||
}
|
||||
e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres';
|
||||
// p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas
|
||||
// les 15 Go du fichier dont le lien a été collé.
|
||||
const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : '';
|
||||
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '+fmtSize(p.free)+' libres';
|
||||
const r = await jpost('/api/models/download', {url, dir});
|
||||
if(!r.ok){
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
||||
@@ -3398,7 +3491,10 @@ function watchDownload(fname){
|
||||
extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s');
|
||||
}
|
||||
}
|
||||
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+' — '+fname;
|
||||
// Une seule barre pour tout le modèle ; le compteur de tranches dit où on en
|
||||
// est, sans quoi un téléchargement en 3 fichiers semble se figer par paliers.
|
||||
const part = st.parts > 1 ? ' — fichier '+(st.part||1)+'/'+st.parts : '';
|
||||
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+part+' — '+fname;
|
||||
};
|
||||
dlPoll = setInterval(tick, 800);
|
||||
tick();
|
||||
|
||||
@@ -186,6 +186,12 @@
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- L'adresse ci-dessus ne sert à RIEN si le moteur n'écoute que sur la
|
||||
boucle locale (le défaut sous Windows) : le chat du navigateur marche,
|
||||
mais aucun logiciel du réseau ne peut s'y brancher. Cet interrupteur
|
||||
règle HOST et pose la règle de pare-feu, voir sys_network.go. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="lan-toggle" onchange="toggleLAN()"><span class="slider"></span></span> joignable depuis le réseau local<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Éteint, le moteur n'écoute que sur <b>cette machine</b> : l'adresse ci-dessus ne répond qu'ici. Allumez-le pour brancher un logiciel depuis un autre ordinateur du réseau (sous Windows, la règle de pare-feu est posée en même temps). <b>Redémarre le moteur.</b></span></span></label>
|
||||
<div id="lan-warn" class="rowbody" style="display:none"></div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend votre IA joignable depuis <b>n'importe où</b> via <code>https://<machine>.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite l'accès distant actif.</span></span></label>
|
||||
<div id="oai-public-wrap" class="rowbody" style="display:none">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse publique — chiffrée de bout en bout, accessible partout</div>
|
||||
@@ -293,6 +299,14 @@
|
||||
<button onclick="resetChat()">⌫ clear chat</button>
|
||||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||||
</div>
|
||||
<!-- Export du fil. Depuis la 0.8 la conversation vit dans ajean.db (bbolt,
|
||||
binaire et verrouillé tant que le service tourne) : sans ce bouton,
|
||||
plus personne ne peut relire ni archiver ses échanges, ce que
|
||||
permettait l'ancien conversation.json. -->
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button onclick="exportChat('md')" title="conversation complète, raisonnements compris">⤓ exporter (.md)</button>
|
||||
<button onclick="exportChat('json')" title="format fidèle : vue modèle + journal d'affichage">.json</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -251,6 +251,75 @@ function renderApiKey(d){
|
||||
document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none';
|
||||
}
|
||||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||||
// --- Export de la conversation ---------------------------------------------
|
||||
// On passe par jfetch (et pas par un simple <a href>) pour deux raisons : la clé
|
||||
// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas,
|
||||
// et le chemin de base change derrière le tunnel (/u/<id>).
|
||||
async function exportChat(format){
|
||||
toast('préparation de l\'export…');
|
||||
try{
|
||||
const r = await jfetch('/api/chat/export?format=' + encodeURIComponent(format||'md'));
|
||||
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
|
||||
const blob = await r.blob();
|
||||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom local.
|
||||
let name = 'ajean-conversation.' + (format==='json' ? 'json' : 'md');
|
||||
const cd = r.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
if(m) name = m[1];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
|
||||
// dans la foulée du clic.
|
||||
setTimeout(()=>URL.revokeObjectURL(url), 10000);
|
||||
toast('exporté : ' + name);
|
||||
}catch(e){ toast('erreur : ' + e.message); }
|
||||
}
|
||||
// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------
|
||||
// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le
|
||||
// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le
|
||||
// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part.
|
||||
function renderNetwork(st){
|
||||
if(!st) return;
|
||||
const cb = document.getElementById('lan-toggle');
|
||||
if(cb) cb.checked = !!st.exposed;
|
||||
const warn = document.getElementById('lan-warn');
|
||||
if(!warn) return;
|
||||
if(!st.exposed){
|
||||
warn.style.display = '';
|
||||
warn.innerHTML = '<div class="muted" style="margin:0">Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.</div>';
|
||||
return;
|
||||
}
|
||||
// Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute
|
||||
// partout » et pourtant rien ne passe). On donne la commande à coller.
|
||||
if(st.hint){
|
||||
warn.style.display = '';
|
||||
// --err et non --warn : la palette est volontairement monochrome et --warn y
|
||||
// est un gris, illisible comme alerte. Ici il y a une vraie action à faire.
|
||||
warn.innerHTML = '<div style="margin:0;color:var(--err)">⚠ '
|
||||
+ escHtml(st.hint).replace(/\n/g,'<br>') + '</div>';
|
||||
return;
|
||||
}
|
||||
warn.style.display = 'none';
|
||||
}
|
||||
async function loadNetwork(){
|
||||
try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){}
|
||||
}
|
||||
async function toggleLAN(){
|
||||
const cb = document.getElementById('lan-toggle');
|
||||
const on = cb.checked;
|
||||
const r = await jpost('/api/network', {exposed:on});
|
||||
if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; }
|
||||
renderNetwork(r.status);
|
||||
// llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur
|
||||
// affiche un état que le moteur en cours ne respecte pas encore.
|
||||
if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).',
|
||||
{title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){
|
||||
await act('restart'); // même chemin que les boutons du panneau Moteur
|
||||
}
|
||||
loadApiKey();
|
||||
}
|
||||
function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; }
|
||||
async function apiKeyAction(action){
|
||||
if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return;
|
||||
@@ -317,7 +386,7 @@ async function loadAll(){
|
||||
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
|
||||
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||
// hauteurs réservées en place pour toujours.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -223,7 +223,14 @@ async function populateModelPicker(){
|
||||
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
|
||||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
|
||||
if(on) matched = true;
|
||||
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
|
||||
// (les autres ne se lancent pas seules) et la taille de la famille entière.
|
||||
// On le dit, et surtout on signale les tranches manquantes — sinon le
|
||||
// modèle se sélectionne sans broncher puis le moteur meurt au démarrage.
|
||||
let tag = '';
|
||||
if(m.shards > 1) tag = ' · ' + m.shards + ' fichiers';
|
||||
if(m.missing && m.missing.length) tag += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
|
||||
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+tag+')</option>';
|
||||
}
|
||||
html += groups.length > 1
|
||||
? '<optgroup label="'+escHtml(g.home ? 'dossier ajean' : g.dir)+'">'+opts+'</optgroup>'
|
||||
@@ -837,7 +844,10 @@ async function startDownload(){
|
||||
await populateDlDirs();
|
||||
return;
|
||||
}
|
||||
e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres';
|
||||
// p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas
|
||||
// les 15 Go du fichier dont le lien a été collé.
|
||||
const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : '';
|
||||
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '+fmtSize(p.free)+' libres';
|
||||
const r = await jpost('/api/models/download', {url, dir});
|
||||
if(!r.ok){
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
||||
@@ -906,7 +916,10 @@ function watchDownload(fname){
|
||||
extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s');
|
||||
}
|
||||
}
|
||||
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+' — '+fname;
|
||||
// Une seule barre pour tout le modèle ; le compteur de tranches dit où on en
|
||||
// est, sans quoi un téléchargement en 3 fichiers semble se figer par paliers.
|
||||
const part = st.parts > 1 ? ' — fichier '+(st.part||1)+'/'+st.parts : '';
|
||||
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+part+' — '+fname;
|
||||
};
|
||||
dlPoll = setInterval(tick, 800);
|
||||
tick();
|
||||
|
||||
@@ -281,6 +281,12 @@ func handleModels(w http.ResponseWriter, r *http.Request) {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") {
|
||||
continue
|
||||
}
|
||||
// Modèle découpé : seule la première tranche est un modèle lançable, les
|
||||
// suivantes sont ouvertes par llama-server tout seul. Les lister toutes
|
||||
// donnait trois entrées pour un modèle, dont deux qui échouent au démarrage.
|
||||
if isFollowerShard(e.Name()) {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, e.Name())
|
||||
if seen[normDir(full)] {
|
||||
continue
|
||||
@@ -295,10 +301,21 @@ func handleModels(w http.ResponseWriter, r *http.Request) {
|
||||
if isHome {
|
||||
value = e.Name()
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
m := map[string]any{
|
||||
"name": e.Name(), "size": size, "value": value,
|
||||
"path": full, "dir": dir, "home": isHome,
|
||||
})
|
||||
}
|
||||
// Taille annoncée = la famille entière, et on signale les tranches
|
||||
// manquantes : un modèle incomplet démarre puis meurt sur un tenseur
|
||||
// introuvable, autant le voir avant de le sélectionner.
|
||||
if _, total, ok := shardInfo(e.Name()); ok {
|
||||
m["shards"] = total
|
||||
m["size"] = shardFamilySize(dir, e.Name())
|
||||
if missing := shardFamilyMissing(dir, e.Name()); len(missing) > 0 {
|
||||
m["missing"] = missing
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, out)
|
||||
|
||||
@@ -160,6 +160,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/mcp/tool", handleMCPTool)
|
||||
api("/api/mcp/test", handleMCPTest)
|
||||
api("/api/memory", handleMemoryMode)
|
||||
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
||||
api("/api/prefs", handleWebPrefs)
|
||||
api("/api/sysprompt", handleSysPrompt)
|
||||
// Alias rétro-compat : l'ancien portail ajean.link (dépôt ajean-relay) pilote
|
||||
@@ -184,6 +185,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/chat/reset", handleChatReset) // nouvelle conversation (pour tous les appareils)
|
||||
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
||||
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
||||
return mux
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user