v0.9.2 : postes distants dans le chat + envoi de fichiers via ajean.link reparé

- UI : bouton PC/wifi dans la barre de saisie → modale « Postes distants »
  (cible d'exécution, liste, appairage) ; bloc retiré des réglages
- envoi de fichiers derrière ajean.link : sendChunk passe par jfetch (E2E) au
  lieu d'un fetch brut qui n'atteignait pas le serveur derrière le relais
- ajean remote install ré-exécutable : --code force l'appairage, et nodeclient
  retire le service en cours + remplace le binaire verrouillé (Windows/Linux)
- MAJ depuis l'UI : restartAfterUpdate en sudo -n quand le service tourne en
  non-root ; références ajean-link → ajean-ui ; hint de restart corrigé
This commit is contained in:
nathaninline committed 2026-08-14 10:31:46 +02:00
1 parent 88d1e79e80
commit e3957fa749
18 files changed
+248 -82

No files matched your search

+11 -11
View File
@@ -1,19 +1,19 @@
Le poste distant est maintenant intégré à AJEAN : plus de binaire séparé, plus de téléchargement à part.
Les postes distants se pilotent maintenant depuis le chat, et l'envoi de fichiers à travers ajean.link est réparé.
## Une seule application
## Les postes distants à portée de main
Jusqu'ici, piloter un autre PC demandait d'installer un second binaire, « ajean-remote ». C'était une application de plus à télécharger, à suivre et à mettre à jour. On l'a supprimée : tout vit désormais dans **AJEAN**.
Un nouveau bouton apparaît dans la barre de saisie, juste à côté du trombone. Son icône dit **sur quelle machine l'IA agit** : un écran quand c'est ce serveur, des ondes wifi quand c'est un poste distant. Un clic ouvre une **fenêtre dédiée** où tu choisis la cible, vois tes postes appairés (avec leurs capacités et leur dossier autorisé) et en ajoutes de nouveaux.
Sur le PC à piloter, tu installes simplement AJEAN, puis tu lances :
Plus besoin d'aller fouiller dans les réglages : tout est là, au moment où on en a besoin.
```
ajean remote install https://ajean.link --machine <id> --key <clé> --code <code> --allow shell,read,write,list
```
## Envoi de fichiers réparé sur ajean.link
La commande s'installe en service — invisible, au démarrage, reconnexion automatique — exactement comme avant. Rien ne change au chiffrement : l'accès reste **chiffré de bout en bout**, le relais ajean.link ne voit toujours rien.
Depuis l'accès distant, joindre un fichier à un message échouait. La cause : l'envoi ne passait pas par le tunnel chiffré de bout en bout, contrairement au téléchargement. C'est corrigé — l'envoi emprunte désormais le même chemin, et un gigaoctet traverse toujours par morceaux, sans que le relais ne voie rien.
## Rien à changer dans ton usage
## Installer un poste, en une commande ré-exécutable
Dans **Réglages → Postes distants**, générer un code affiche la commande prête à copier — à distance (via ajean.link) ou en réseau local. Seul le nom de la commande change : `ajean remote install …` au lieu de `ajean-remote install …`.
`ajean remote install …` peut maintenant être relancé tel quel : fournir un code d'appairage force toujours le (ré)appairage, et la commande retire proprement un service déjà en place au lieu d'échouer sur un fichier verrouillé. Fini le `logout` manuel entre deux essais.
Les postes déjà appairés continuent de fonctionner sans réappairage.
## Mise à jour depuis l'interface, même sans root
Sur un serveur où le service tourne sous un compte non-root, le bouton « mettre à jour » de l'interface redémarre désormais correctement le service (via sudo non interactif) au lieu d'échouer en silence.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 9,
"Patch": 1,
"Patch": 2,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 9,
"Patch": 1,
"Patch": 2,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "ajean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.9.1",
"ProductVersion": "0.9.2",
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
},
"VarFileInfo": {
+1 -1
View File
@@ -135,7 +135,7 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool {
}
// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur
// (donc dans `journalctl -u ajean-link`). Sans ça, une compaction qui ne se
// (donc dans `journalctl -u ajean-ui`). Sans ça, une compaction qui ne se
// déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté
// UI on ne voit qu'une jauge qui reste haute, sans savoir si le seuil n'a pas
// été atteint ou si la réduction a été refusée.
+1 -1
View File
@@ -40,7 +40,7 @@ func cmdApp(args []string) error {
return openBrowser(url)
}
// UN SEUL process propriétaire de la conversation, comme le service ajean-link
// UN SEUL process propriétaire de la conversation, comme le service ajean-ui
// sous Linux : l'app sert l'UI locale ET le tunnel avec le MÊME mux. Deux
// process qui servent la conversation (objet en mémoire persisté dans
// conversation.json) donnent deux fils divergents entre le local et
+1 -1
View File
@@ -315,7 +315,7 @@ func uiPIDPath() string { return filepath.Join(AjeanHome(), uiUnitName+".pid") }
func uiLogPath() string { return filepath.Join(AjeanHome(), uiUnitName+".log") }
// uiServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est
// l'unité systemd ajean-link (avec sudo non interactif si on n'est pas root) ;
// l'unité systemd ajean-ui (avec sudo non interactif si on n'est pas root) ;
// ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance
// « ajean link serve » en processus détaché suivi par un fichier PID, comme le
// fait déjà le service principal. Sans ça, l'accès distant restait définitivement
+7 -1
View File
@@ -82,7 +82,13 @@ Options :
func remoteConnect(args []string, install bool) error {
f := parseRemoteFlags(args)
cfg, lerr := nodeclient.LoadConfig()
needEnroll := lerr != nil || cfg.Priv == ""
// Un --code est un jeton d'appairage à usage unique : le fournir veut TOUJOURS
// dire « (ré)appaire-moi maintenant ». Sans ce « || f.code != "" », une config
// résiduelle (test précédent, ancien binaire ajean-remote au même emplacement
// %ProgramData%\ajean-remote\config.json) faisait sauter l'appairage en silence :
// le service démarrait avec de vieilles clés que le serveur ignore et le poste
// ne remontait jamais, alors que tout affichait « [ok] ».
needEnroll := lerr != nil || cfg.Priv == "" || f.code != ""
if f.url != "" {
cfg.ServerURL = f.url
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.9.1"
const Version = "0.9.2"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
+20 -12
View File
@@ -434,7 +434,7 @@ func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
// handleUpdateApply (POST /api/update/apply) : télécharge et installe la dernière
// version. Sur un serveur Linux/systemd, relance ensuite AUTOMATIQUEMENT le service
// ajean-link (celui qui sert l'UI, le chat et le tunnel) pour appliquer la MAJ sans
// d'UI (ajean-ui : il sert l'UI, le chat et le tunnel) pour appliquer la MAJ sans
// avoir à SSH — voir restartAfterUpdate. Renvoie la version + le message de statut.
func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
newVer, err := applyUpdate()
@@ -450,15 +450,15 @@ func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "version": newVer, "restart": msg, "restarting": restarting})
}
// restartAfterUpdate relance le service ajean-link juste après une MAJ déclenchée
// depuis l'UI, pour éviter le SSH manuel. Conditions : Linux/systemd + ajean-link
// actif (le service tourne en root → systemctl passe sans sudo). On NE touche PAS à
// ajean.service (llama-server) pour ne pas recharger le modèle (long et inattendu
// depuis un bouton « mettre à jour »). Le redémarrage est DIFFÉRÉ (la réponse HTTP
// doit partir d'abord) et lancé en --no-block : systemd enregistre le job puis nous
// arrête/relance ; la clé E2E (.e2e_key) survit au restart, donc pas de ré-appairage
// et l'UI se reconnecte toute seule (boucle de reconnexion du flux). Renvoie
// (déclenché, message). Non-serveur (poste client, Windows) → (false, "").
// restartAfterUpdate relance le service d'UI (uiServiceName, « ajean-ui » : il sert
// l'interface, le chat et le tunnel) juste après une MAJ déclenchée depuis l'UI, pour
// éviter le SSH manuel. Conditions : Linux/systemd + service actif. On NE touche PAS à
// ajean-engine (llama-server) pour ne pas recharger le modèle (long et inattendu depuis
// un bouton « mettre à jour »). Le redémarrage est DIFFÉRÉ (la réponse HTTP doit partir
// d'abord) et lancé en --no-block : systemd enregistre le job puis nous arrête/relance ;
// la clé E2E (.e2e_key) survit au restart, donc pas de ré-appairage et l'UI se reconnecte
// toute seule (boucle de reconnexion du flux). Renvoie (déclenché, message). Non-serveur
// (poste client, Windows) → (false, "").
func restartAfterUpdate() (bool, string) {
// Windows : pas de superviseur pour nous relancer, on delegue a un
// accompagnateur detache (voir sys_restart_windows.go). C'est aussi ce qui
@@ -472,9 +472,16 @@ func restartAfterUpdate() (bool, string) {
}
go func() {
time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client
// Le service tourne souvent en User=nathan (pas root) : systemctl brut
// échouerait alors (polkit). On repli sur « sudo -n systemctl » comme
// uiServiceCtl, les sudoers /etc/sudoers.d/ajean-ui autorisant le restart.
// --no-block : on enregistre le job puis on rend la main ; systemd exécute le
// stop/start même si ce process (et le client systemctl) sont tués entre-temps.
_ = exec.Command("systemctl", "--no-block", "restart", uiServiceName()).Run()
bin, args := "systemctl", []string{"--no-block", "restart", uiServiceName()}
if os.Geteuid() != 0 {
bin, args = "sudo", append([]string{"-n", "systemctl"}, args...)
}
_ = exec.Command(bin, args...).Run()
}()
return true, "Service " + uiServiceName() + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)."
}
@@ -483,7 +490,8 @@ func restartHintText() string {
if runtime.GOOS == "windows" {
return "Redémarre AJEAN (quitte puis relance) pour appliquer la mise à jour."
}
return "Redémarre les services pour appliquer : sudo systemctl restart ajean ajean-link"
return "Redémarre pour appliquer : sudo systemctl restart " + uiServiceName() +
" (ajoute " + serviceName() + " si la mise à jour touche le moteur)."
}
func printRestartHint() { fmt.Println(restartHintText()) }
+79 -23
View File
@@ -1052,6 +1052,20 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#attach:active{transform:scale(.9)}
#attach svg{display:block}
/* Bouton « Postes distants » du composeur : même gabarit que le trombone. L'icône
dépend de la cible active — écran (local) par défaut, ondes wifi quand un poste
distant est sélectionné, alors teinté en accent pour signaler « l'IA agit
ailleurs ». Les deux SVG coexistent ; on n'en montre qu'un via la classe. */
#node-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
display:flex;align-items:center;justify-content:center;border-radius:999px;
background:transparent;border:1px solid transparent;color:var(--dim)}
#node-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#node-btn:active{transform:scale(.9)}
#node-btn svg{display:block}
#node-btn .ic-remote{display:none}
#node-btn.remote{color:var(--accent)}
#node-btn.remote .ic-here{display:none}
#node-btn.remote .ic-remote{display:block}
/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte.
Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de
dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant
@@ -1302,15 +1316,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
</div>
<div id="node-block" class="gated-group">
<div class="subhead">Postes distants<span id="node-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Pilote un <b>autre PC</b> depuis l'IA. installe <b>ajean</b> sur le PC secondaire et lance <code>ajean remote install &lt;url&gt; --code CODE</code> : il se connecte en sortant (aucun port à ouvrir) et expose à l'IA un jeu d'outils restreint (shell, fichiers). ⚠ tu décides des capacités et du dossier autorisé pour chaque poste ; le poste peut aussi demander confirmation localement.</span></span></div>
<div class="slist">
<div class="rowbody">
<div id="node-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l'IA puisse agir dessus)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openNodePair()">+ appairer un poste</button>
</div>
</div>
</div>
<div id="param-block" class="gated-group">
<div class="subhead">Paramètres</div>
<div class="slist">
@@ -1504,6 +1509,13 @@ button:hover{border-color:var(--dim);color:var(--text)}
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
<span id="ctx-text" class="muted">0 / 0</span>
<button id="ctx-compact" onclick="compactContext()">compacter</button>
<!-- Postes distants : l'icône dit SUR QUOI l'IA agit. Écran = ce serveur
(cible locale) ; ondes wifi = un poste distant est la cible active.
Un clic ouvre la modale de gestion (cible, liste, appairage). -->
<button id="node-btn" onclick="openNodeHub()" title="Postes distants" aria-label="Postes distants">
<svg class="ic-here" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<svg class="ic-remote" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.55a11 11 0 0 1 14 0"/><path d="M8.5 16.1a6 6 0 0 1 7 0"/><path d="M12 20h.01"/></svg>
</button>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
@@ -1704,6 +1716,23 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
</div>
<div id="node-hub-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-hub-modal')">
<div class="modal-card">
<div class="modal-head">
<strong>Postes distants</strong>
<button class="modal-x" onclick="hideModal('node-hub-modal')" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="muted" style="font-size:12px;line-height:1.5;margin-bottom:10px">
Pilote un <b>autre PC</b> depuis l'IA. Choisis sur quelle machine l'IA agit
(ses outils shell/fichiers) et appaire de nouveaux postes. Tu décides des
capacités et du dossier autorisé pour chaque poste.
</div>
<div id="node-hub-list" class="mcp-list"></div>
<button class="addbtn" style="margin-top:10px" onclick="openNodePair()">+ appairer un poste</button>
</div>
</div>
</div>
<div id="node-pair-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-pair-modal')">
<div class="modal-card">
<div class="modal-head">
@@ -2538,7 +2567,7 @@ async function loadAgent(){
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','mcp-block','node-block','param-block'].forEach(id=>{
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
@@ -5095,6 +5124,9 @@ async function remotePairCode(){
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
const ATTACH_MAX = 1024*1024*1024;
const ATTACH_CHUNK = 8*1024*1024;
// Délai par MORCEAU (et pas les 30 s d'un appel /api/* ordinaire) : une tranche de
// 8 Mo, base64 + tunnel E2E, peut dépasser 30 s sur une liaison lente.
const UPLOAD_CHUNK_TIMEOUT_MS = 120000;
let ATTACH = [];
let ATTACH_SEQ = 0;
@@ -5298,16 +5330,24 @@ function readAsBase64(file){
fr.readAsDataURL(file);
});
}
// Envoie UN morceau. Pas de jfetch : son délai de 30 s est calibré pour de
// petits appels et couperait un transfert.
// Envoie UN morceau. VIA jfetch, pas un fetch brut : derrière app.ajean.link,
// jfetch est réécrit pour traverser la boîte noire E2E — un POST direct vers
// /api/chat/upload n'atteint PAS ton serveur (le relais est aveugle) et l'envoi
// échouait « impossible d'envoyer ». On fournit notre PROPRE signal pour ne pas
// hériter du plafond de 30 s de jfetch (calibré pour de petits appels), un morceau
// pouvant être plus long ; le download passe déjà par jfetch, on symétrise.
async function sendChunk(payload){
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify(payload)
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
const ac=new AbortController();
const timer=setTimeout(()=>ac.abort(), UPLOAD_CHUNK_TIMEOUT_MS);
try{
const r=await jfetch('/api/chat/upload',{
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload), signal:ac.signal
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
} finally { clearTimeout(timer); }
}
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
@@ -5404,18 +5444,34 @@ const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister
async function loadNode(){ renderNode(await jget('/api/node')); }
// Ouvre la modale de gestion (rafraîchit d'abord pour un état à jour). Appelée
// par le bouton PC/wifi du composeur.
function openNodeHub(){ loadNode(); showModal('node-hub-modal'); }
// Le bouton du composeur reflète SUR QUOI l'IA agit : écran = ce serveur, ondes
// wifi (teinté accent) = un poste distant est la cible active.
function updateNodeBtn(){
const btn = document.getElementById('node-btn');
if(!btn) return;
const remote = !!nodeTarget;
btn.classList.toggle('remote', remote);
const n = remote ? nodeList.find(x=>x.slug===nodeTarget) : null;
btn.title = remote ? ('Postes distants — l\'IA agit sur « '+(n?n.name:nodeTarget)+' »') : 'Postes distants — l\'IA agit sur ce serveur';
}
function renderNode(r){
nodeList = (r && r.nodes) || [];
nodeTarget = (r && r.target) || '';
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
const connected = nodeList.filter(n=>n.connected).length;
setBadge('node-badge', connected ? connected : null);
const list = document.getElementById('node-list');
updateNodeBtn();
const list = document.getElementById('node-hub-list');
if(!list) return;
list.textContent = '';
if(!nodeList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l\'IA puisse agir dessus)</div>';
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste appairé — appaire un PC pour que l\'IA puisse agir dessus)</div>';
return;
}
const connected = nodeList.filter(n=>n.connected).length;
// Sélecteur de CIBLE : sur quelle machine l'IA agit (ses outils bash/write/edit).
renderNodeTarget(list, connected);
nodeList.forEach(n=>{
+24 -9
View File
@@ -146,15 +146,6 @@
</div>
</div>
</div>
<div id="node-block" class="gated-group">
<div class="subhead">Postes distants<span id="node-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Pilote un <b>autre PC</b> depuis l'IA. installe <b>ajean</b> sur le PC secondaire et lance <code>ajean remote install &lt;url&gt; --code CODE</code> : il se connecte en sortant (aucun port à ouvrir) et expose à l'IA un jeu d'outils restreint (shell, fichiers). ⚠ tu décides des capacités et du dossier autorisé pour chaque poste ; le poste peut aussi demander confirmation localement.</span></span></div>
<div class="slist">
<div class="rowbody">
<div id="node-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l'IA puisse agir dessus)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openNodePair()">+ appairer un poste</button>
</div>
</div>
</div>
<div id="param-block" class="gated-group">
<div class="subhead">Paramètres</div>
<div class="slist">
@@ -348,6 +339,13 @@
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
<span id="ctx-text" class="muted">0 / 0</span>
<button id="ctx-compact" onclick="compactContext()">compacter</button>
<!-- Postes distants : l'icône dit SUR QUOI l'IA agit. Écran = ce serveur
(cible locale) ; ondes wifi = un poste distant est la cible active.
Un clic ouvre la modale de gestion (cible, liste, appairage). -->
<button id="node-btn" onclick="openNodeHub()" title="Postes distants" aria-label="Postes distants">
<svg class="ic-here" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<svg class="ic-remote" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.55a11 11 0 0 1 14 0"/><path d="M8.5 16.1a6 6 0 0 1 7 0"/><path d="M12 20h.01"/></svg>
</button>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
@@ -548,6 +546,23 @@
</div>
</div>
</div>
<div id="node-hub-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-hub-modal')">
<div class="modal-card">
<div class="modal-head">
<strong>Postes distants</strong>
<button class="modal-x" onclick="hideModal('node-hub-modal')" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="muted" style="font-size:12px;line-height:1.5;margin-bottom:10px">
Pilote un <b>autre PC</b> depuis l'IA. Choisis sur quelle machine l'IA agit
(ses outils shell/fichiers) et appaire de nouveaux postes. Tu décides des
capacités et du dossier autorisé pour chaque poste.
</div>
<div id="node-hub-list" class="mcp-list"></div>
<button class="addbtn" style="margin-top:10px" onclick="openNodePair()">+ appairer un poste</button>
</div>
</div>
</div>
<div id="node-pair-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-pair-modal')">
<div class="modal-card">
<div class="modal-head">
+1 -1
View File
@@ -118,7 +118,7 @@ async function loadAgent(){
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','mcp-block','node-block','param-block'].forEach(id=>{
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
+20 -9
View File
@@ -14,6 +14,9 @@
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
const ATTACH_MAX = 1024*1024*1024;
const ATTACH_CHUNK = 8*1024*1024;
// Délai par MORCEAU (et pas les 30 s d'un appel /api/* ordinaire) : une tranche de
// 8 Mo, base64 + tunnel E2E, peut dépasser 30 s sur une liaison lente.
const UPLOAD_CHUNK_TIMEOUT_MS = 120000;
let ATTACH = [];
let ATTACH_SEQ = 0;
@@ -217,16 +220,24 @@ function readAsBase64(file){
fr.readAsDataURL(file);
});
}
// Envoie UN morceau. Pas de jfetch : son délai de 30 s est calibré pour de
// petits appels et couperait un transfert.
// Envoie UN morceau. VIA jfetch, pas un fetch brut : derrière app.ajean.link,
// jfetch est réécrit pour traverser la boîte noire E2E — un POST direct vers
// /api/chat/upload n'atteint PAS ton serveur (le relais est aveugle) et l'envoi
// échouait « impossible d'envoyer ». On fournit notre PROPRE signal pour ne pas
// hériter du plafond de 30 s de jfetch (calibré pour de petits appels), un morceau
// pouvant être plus long ; le download passe déjà par jfetch, on symétrise.
async function sendChunk(payload){
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify(payload)
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
const ac=new AbortController();
const timer=setTimeout(()=>ac.abort(), UPLOAD_CHUNK_TIMEOUT_MS);
try{
const r=await jfetch('/api/chat/upload',{
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload), signal:ac.signal
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
} finally { clearTimeout(timer); }
}
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
+20 -4
View File
@@ -10,18 +10,34 @@ const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister
async function loadNode(){ renderNode(await jget('/api/node')); }
// Ouvre la modale de gestion (rafraîchit d'abord pour un état à jour). Appelée
// par le bouton PC/wifi du composeur.
function openNodeHub(){ loadNode(); showModal('node-hub-modal'); }
// Le bouton du composeur reflète SUR QUOI l'IA agit : écran = ce serveur, ondes
// wifi (teinté accent) = un poste distant est la cible active.
function updateNodeBtn(){
const btn = document.getElementById('node-btn');
if(!btn) return;
const remote = !!nodeTarget;
btn.classList.toggle('remote', remote);
const n = remote ? nodeList.find(x=>x.slug===nodeTarget) : null;
btn.title = remote ? ('Postes distants — l\'IA agit sur « '+(n?n.name:nodeTarget)+' »') : 'Postes distants — l\'IA agit sur ce serveur';
}
function renderNode(r){
nodeList = (r && r.nodes) || [];
nodeTarget = (r && r.target) || '';
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
const connected = nodeList.filter(n=>n.connected).length;
setBadge('node-badge', connected ? connected : null);
const list = document.getElementById('node-list');
updateNodeBtn();
const list = document.getElementById('node-hub-list');
if(!list) return;
list.textContent = '';
if(!nodeList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l\'IA puisse agir dessus)</div>';
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste appairé — appaire un PC pour que l\'IA puisse agir dessus)</div>';
return;
}
const connected = nodeList.filter(n=>n.connected).length;
// Sélecteur de CIBLE : sur quelle machine l'IA agit (ses outils bash/write/edit).
renderNodeTarget(list, connected);
nodeList.forEach(n=>{
+14
View File
@@ -1026,6 +1026,20 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#attach:active{transform:scale(.9)}
#attach svg{display:block}
/* Bouton « Postes distants » du composeur : même gabarit que le trombone. L'icône
dépend de la cible active — écran (local) par défaut, ondes wifi quand un poste
distant est sélectionné, alors teinté en accent pour signaler « l'IA agit
ailleurs ». Les deux SVG coexistent ; on n'en montre qu'un via la classe. */
#node-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
display:flex;align-items:center;justify-content:center;border-radius:999px;
background:transparent;border:1px solid transparent;color:var(--dim)}
#node-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#node-btn:active{transform:scale(.9)}
#node-btn svg{display:block}
#node-btn .ic-remote{display:none}
#node-btn.remote{color:var(--accent)}
#node-btn.remote .ic-here{display:none}
#node-btn.remote .ic-remote{display:block}
/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte.
Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de
dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant
+5
View File
@@ -100,5 +100,10 @@ func copyFile(src, dst string) error {
if err != nil {
return err
}
// Ré-installation : Linux refuse d'ÉCRASER l'image d'un binaire en cours
// (ETXTBSY « text file busy ») mais autorise à le DÉLIER — le service vivant
// garde son inode ouvert, et le nouveau fichier prend la place du nom. Sans ce
// unlink, « ajean remote install » relancé échouait tant que le service tournait.
_ = os.Remove(dst)
return os.WriteFile(dst, b, 0o755)
}
+40 -5
View File
@@ -61,8 +61,15 @@ func Install() error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
// Ré-installation : un service AjeanRemote déjà présent tient ajean-remote.exe
// OUVERT (verrou Windows sur l'image d'un processus en cours) → la copie
// échouerait avec « used by another process » et CreateService avec « déjà
// installé ». On le retire d'abord (best-effort : absent = rien à faire), ce qui
// arrête le service et libère le fichier, rendant « ajean remote install »
// ré-exécutable tel quel — sans imposer un « uninstall » manuel préalable.
_ = Uninstall()
if abs, _ := filepath.Abs(self); abs != dst {
if err := copyFile(self, dst); err != nil {
if err := replaceExe(self, dst); err != nil {
return fmt.Errorf("copie du binaire: %w", err)
}
}
@@ -71,10 +78,6 @@ func Install() error {
return fmt.Errorf("gestionnaire de services (droits admin requis ?): %w", err)
}
defer m.Disconnect()
if s, err := m.OpenService(serviceName); err == nil {
s.Close()
return fmt.Errorf("service déjà installé — « ajean remote uninstall » d'abord")
}
s, err := m.CreateService(serviceName, dst, mgr.Config{
StartType: mgr.StartAutomatic,
DisplayName: "AJEAN — poste distant",
@@ -107,6 +110,38 @@ func Uninstall() error {
return s.Delete()
}
// replaceExe copie src vers dst, y compris quand dst est un exécutable ENCORE en
// cours. Windows refuse d'écraser l'image d'un processus vivant mais accepte de la
// RENOMMER : on décale l'ancien fichier puis on écrit le nouveau à sa place. Utile
// juste après un Uninstall(), le temps que le service en cours d'arrêt relâche le
// fichier — et en dernier recours si un autre processus le tient encore.
func replaceExe(src, dst string) error {
if err := copyFile(src, dst); err == nil {
return nil
}
// Nom unique : un « .old » figé par un ancien remplacement ne doit pas bloquer
// celui-ci. Ces reliquats sont balayés au passage.
for _, old := range oldBinaries(dst) {
_ = os.Remove(old)
}
aside := fmt.Sprintf("%s.old-%d", dst, time.Now().UnixNano())
if err := os.Rename(dst, aside); err != nil {
return err // ni écrasable ni renommable
}
if err := copyFile(src, dst); err != nil {
_ = os.Rename(aside, dst) // rien ne doit disparaître
return err
}
_ = os.Remove(aside) // échoue tant que l'ancien tourne ; nettoyé au prochain coup
return nil
}
// oldBinaries liste les reliquats « <dst>.old-* » d'un remplacement précédent.
func oldBinaries(dst string) []string {
m, _ := filepath.Glob(dst + ".old-*")
return m
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {