mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
88d1e79e80
commit
e3957fa749
18 files changed
+248
-82
No files matched your search
+11
-11
@@ -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,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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()) }
|
||||
@@ -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 <url> --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=>{
|
||||
|
||||
@@ -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 <url> --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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=>{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user