Merge pull request #5 from R0m1k3/claude/ajean-loki-container-fork-aep9r2

Claude/ajean loki container fork aep9r2
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-16 14:37:02 +02:00
commit d42c334b9a
10 files changed
+1088 -561

No files matched your search

+95 -11
View File
@@ -7,9 +7,10 @@
> **un conteneur Docker GPU autonome**, là où l'amont s'installe en binaire +
> systemd sur la machine hôte.
Loki fait tourner un modèle de langage **100 % en local** : tchat, mémoire
persistante, accès internet, outils MCP, agent (shell, fichiers), accès distant
chiffré — serveur d'inférence llama.cpp compris, dans une seule image.
Loki fait tourner un modèle de langage **100 % en local** : discussions
multiples, mémoire persistante, accès internet, captures de pages web, outils
MCP, agent (shell, fichiers) — serveur d'inférence llama.cpp compris, dans une
seule image.
## Architecture
@@ -19,7 +20,9 @@ chiffré — serveur d'inférence llama.cpp compris, dans une seule image.
│ │ pilote (fichier PID — pas de systemd) │
│ loki serve ──exec──► llama-server (CUDA, :8080) │
│ ▲ modèles .gguf │
│ /data (config, bbolt, mémoire, workspace) │
│ chromium (Playwright) — captures de pages web │
│ /data (config, bbolt, presets, mémoire, │
│ workspace + captures, modèles installés) │
│ /models (GGUF déposés à la main) │
└────────────────────────────────────────────────────┘
```
@@ -102,36 +105,117 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_NGL` | couches GPU initiales | `999` (tout) |
| `LOKI_HOME` | données (volume) | `/data` |
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint | — |
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `test`…).
## Fonctionnalités (héritées d'AJEAN)
### Données et persistance
- **Tchat** avec streaming, raisonnement visible, pièces jointes, vision
(selon modèle), export de conversations.
**Tout** l'état vit sous `/data` (= `LOKI_HOME`). Si ce chemin n'est pas un
volume monté sur l'hôte, il disparaît au premier `docker compose down` — presets
et modèles compris.
| Chemin | Contenu |
|---|---|
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
| `/data/models/` | modèles téléchargés depuis l'interface |
| `/data/memory/` | pages de mémoire persistante (`.md`) |
| `/data/workspace/` | dossier de travail de l'agent |
| `/data/workspace/captures/<id>/` | captures d'écran, un dossier par discussion |
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
| `/models` | GGUF déposés à la main depuis l'hôte (volume séparé, `LOKI_MODEL_DIRS`) |
Vérifier que le volume est bien là :
```bash
docker inspect loki --format '{{range .Mounts}}{{.Source}} → {{.Destination}}{{println}}{{end}}'
```
Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/user/…`
(partage, via FUSE) et `/mnt/cache/…` (disque de cache) désignent des
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
Autre piège au redémarrage du serveur : Docker relance les conteneurs
`restart: unless-stopped` **avant** que le plugin Nvidia Driver ait chargé ses
modules. Le journal montre alors des `ERROR: init … result=11` et le moteur
démarre sans GPU. Un `docker restart loki` une fois le pilote prêt suffit.
## Fonctionnalités
Héritées d'AJEAN :
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
conversations. La **vision** demande un modèle multimodal *et* son projecteur
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
- **Mémoire persistante** (`memory off|ondemand|always`).
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **Accès distant chiffré** via le relais [ajean.link](https://ajean.link)
(service opéré par l'auteur de l'amont).
- **API OpenAI-compatible** exposable (`network on`, protégée par clé).
Ajoutées par ce fork :
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. Supprimer une discussion
efface aussi ses captures d'écran (`workspace/captures/<id>/`), pour que le
disque ne se remplisse pas en silence.
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
(Chromium via Playwright, inclus dans l'image). Les captures partent en JPEG
et sont plafonnées à 20 fichiers / 40 Mo par discussion. La description de
l'outil suit la capacité **réelle** du moteur, sondée sur `/props` : sans
vision effective, elle dit au modèle « tu ne vois pas l'image » plutôt que de
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
- **Panneau Fichiers** (bouton dossier du pied de carte) : ce que l'agent a
écrit dans son dossier de travail, avec navigation dans les sous-dossiers,
téléchargement et suppression. Un dossier affiche la taille de **tout** son
contenu — c'est ce qu'on libère en le supprimant. Le pied donne l'occupation
disque totale de l'agent. Les chemins sont bornés au dossier de travail, liens
symboliques résolus des deux côtés : rien du reste du disque n'est atteignable.
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
dans le fil.
- **Paramètres** : les réglages d'application (identité, apparence, accès
OpenAI, actions) sont regroupés à part des réglages d'IA.
Retirées par ce fork :
- **Accès distant via [ajean.link](https://ajean.link)** : la section de
l'interface et son module JS sont supprimés — un conteneur derrière son
propre réseau n'en a pas l'usage. Le code serveur du relais reste en place
mais **inerte** (aucun jeton, aucune section pour en fournir un) : le retirer
créerait un conflit à chaque reprise de l'amont.
- **Postes distants** (faire agir l'agent sur un autre PC appairé) : bouton du
composeur, modales d'appairage et module JS supprimés. Même traitement que
ci-dessus — les routes `/api/node/*` subsistent mais plus rien ne peut
générer de code d'appairage, donc aucun poste ne peut se connecter.
- **Catalogue de modèles distant** : il interrogeait `ajean.link/models.json`,
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
recherche Hugging Face le remplace.
## Différences avec l'amont
| | AJEAN (amont) | Loki (ce fork) |
|---|---|---|
| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` |
| Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | image officielle llama.cpp (`server-cuda`), précompilée |
| Panneau « Moteur » | propose d'installer/compiler | annonce le moteur fourni par l'image, sans proposer d'install |
| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) |
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
| Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié |
| Historique de tchat | conversation unique | discussions multiples, titrées et persistées |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
Le reste — UI, mémoire, outils, protocole — est celui d'AJEAN. Pour récupérer
les évolutions de l'amont :
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
Pour récupérer les évolutions de l'amont :
```bash
git fetch upstream && git merge upstream/main # conflits de renommage à arbitrer
+259 -264
View File
@@ -1140,20 +1140,6 @@ 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
@@ -1269,21 +1255,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
/* Postes distants : cases de capacités, code d'appairage, commande. */
.node-caps{display:flex;flex-wrap:wrap;gap:6px}
.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none}
.node-cap input{margin:0}
.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0}
.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px}
/* Poste distant : sélecteur de cible d'exécution. */
.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px}
.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%}
.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer}
.node-target-opt.on{border-color:var(--accent);color:var(--accent)}
.node-target-opt.off{opacity:.6}
.node-target-opt input{margin:0}
/* ===== Direction « Terre » : surfaces en cartes =======================
La charte pose des CARTES arrondies séparées par une gouttière, au lieu
d'une colonne à filets. Bloc en fin de feuille pour prendre le pas sur les
@@ -1420,6 +1391,75 @@ button:hover{border-color:var(--dim);color:var(--text)}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default}
/* --- Panneau « Fichiers » (droite) ------------------------------------
Symétrique des réglages à gauche : une colonne fixe qui pousse la
conversation au lieu de la recouvrir. C'est possible ici parce que la largeur
de lecture est pilotée par --measure et que #chat se recentre tout seul dans
la place qui reste — le fil se resserre, il ne passe pas dessous.
Le panneau est retiré du flux quand il est fermé (et non simplement décalé) :
sinon il conserverait sa gouttière et le fil resterait décentré. */
#files{display:none;width:320px;flex-shrink:0;border-left:1px solid var(--border);
background:var(--panel);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));
overflow-y:auto;flex-direction:column;gap:10px}
html[data-files="1"] #files{display:flex}
#files-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:14}
.files-head{display:flex;align-items:center;gap:8px}
.files-head .stitle{flex:1}
#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:8px}
#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
discussion dans le chemin dépasse largement 320 px. */
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px}
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:7px;
background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
.files-crumb-b.on{color:var(--text);cursor:default}
.files-crumb-s{color:var(--dim);opacity:.5}
.files-list{display:flex;flex-direction:column;flex:1;min-height:0}
.files-empty{font-size:12px;line-height:1.5;padding:10px 2px}
/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
survol : au repos on veut lire des noms de fichiers, pas une grille de
boutons. Sur écran tactile (pas de survol) elles restent visibles. */
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;font-size:12px}
.files-row:hover{background:var(--row-bg-on)}
.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
.files-row.isdir .files-ic{color:var(--accent)}
.files-mid{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
.files-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.files-meta{font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
.files-acts{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .12s}
.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
@media (hover:none){.files-acts{opacity:1}}
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border)}
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
.files-row.busy{opacity:.55;pointer-events:none}
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand
le panneau est ouvert — l'état se lit sans regarder à droite. */
#files-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)}
#files-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#files-btn:active{transform:scale(.9)}
#files-btn svg{display:block}
html[data-files="1"] #files-btn{color:var(--accent)}
/* Petit écran : tiroir par la droite, comme les réglages par la gauche. Pousser
une colonne de 320 px sur un téléphone ne laisserait rien à la conversation. */
@media (max-width:720px){
#files{position:fixed;inset:0 0 0 auto;width:85%;max-width:340px;z-index:15;
background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));
padding-right:calc(16px + env(safe-area-inset-right))}
html[data-files="1"] #files-backdrop{display:block}
}
@media (min-width:721px){
/* Dans la charte « Terre », le panneau est une carte comme les autres. */
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
box-shadow:var(--shadow);margin:0}
}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -1756,12 +1796,11 @@ 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>
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
gabarit que le trombone — ce sont les deux faces du même sujet,
ce qui entre et ce qui sort. -->
<button id="files-btn" onclick="toggleFiles()" title="Fichiers du dossier de travail" aria-label="Fichiers du dossier de travail" aria-expanded="false">
<svg 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="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></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">
@@ -1783,6 +1822,24 @@ button:hover{border-color:var(--dim);color:var(--text)}
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div>
</div>
<!-- Fichiers : ce que l'agent a écrit dans son dossier de travail. Panneau de
DROITE, symétrique des réglages à gauche, ouvert et fermé par le bouton
dossier du pied de carte. Il ne se remplit qu'à l'ouverture (loadFiles) —
un ReadDir à chaque chargement de page pour un panneau fermé serait payé
par tout le monde et utile à personne. -->
<div id="files-backdrop" onclick="toggleFiles()"></div>
<aside id="files" aria-label="Fichiers du dossier de travail">
<div class="files-head">
<span class="stitle">Fichiers</span>
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
</div>
<!-- Fil d'Ariane : le dossier de travail contient des sous-dossiers (captures
par discussion), il faut pouvoir y descendre et en remonter. -->
<div id="files-crumb" class="files-crumb"></div>
<div id="files-list" class="files-list"></div>
<div id="files-foot" class="files-foot muted"></div>
</aside>
<div id="toast"></div>
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
@@ -1963,59 +2020,6 @@ 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">
<strong>Appairer un poste distant</strong>
<button class="modal-x" onclick="hideModal('node-pair-modal')" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="fld">
<label class="fld-l">Capacités autorisées <span class="opt">(ce que l'IA pourra faire sur ce poste)</span></label>
<div id="node-pair-caps" class="node-caps"></div>
</div>
<div class="fld">
<label class="fld-l" for="node-pair-root">Dossier autorisé <span class="opt">(confine lecture/écriture ; laisser vide = dossier de travail par défaut du poste)</span></label>
<input id="node-pair-root" class="ctl" placeholder="C:\\Users\\moi\\loki ou /home/moi/loki" spellcheck="false">
</div>
<div id="node-pair-out" style="display:none">
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5">
Sur le PC à piloter, installe <b>loki</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
<pre class="node-cmd" id="node-pair-cmd"></pre>
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="hideModal('node-pair-modal')">fermer</button>
<button class="btn-save" onclick="genNodeCode()">générer le code</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
@@ -3214,10 +3218,13 @@ async function clearCrawlKey(){
toast('clé retirée');
}
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(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
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); }
@@ -5941,192 +5948,180 @@ async function attachPaths(){
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
}
renderAttach();
// ─── Postes distants ─────────────────────────────────────────────────────────
// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que
// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit
// les capacités autorisées et le dossier racine, puis peut lister/révoquer les
// postes. Sous-réglage du mode agent (grisé quand l'agent est off).
let nodeList = [];
let nodeTarget = '';
let nodeAllCaps = ['shell','read','write','list'];
const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister'};
// ─── Fichiers du dossier de travail ──────────────────────────────────────────
// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici
// on ne pouvait récupérer un fichier que si le modèle avait pensé à en mettre le
// lien dans sa réponse — tout le reste restait invisible, et faire le ménage
// demandait un shell.
//
// Le panneau est construit EN DOM, jamais en innerHTML : les noms de fichiers
// viennent du modèle et du système de fichiers, donc d'ailleurs. Même règle que
// pour les titres de discussions (16-conversations.js).
//
// Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
// déjà gérer le blob et les cas particuliers du navigateur.
async function loadNode(){ renderNode(await jget('/api/node')); }
let FILES_DIR = ''; // dossier affiché, relatif au dossier de travail
let FILES_OPEN = false;
// 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';
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde
// souvent le panneau ouvert, et le rouvrir à chaque rechargement est pénible.
function toggleFiles(force){
FILES_OPEN = (force === undefined) ? !FILES_OPEN : !!force;
document.documentElement.setAttribute('data-files', FILES_OPEN ? '1' : '0');
const btn = document.getElementById('files-btn');
if(btn) btn.setAttribute('aria-expanded', FILES_OPEN ? 'true' : 'false');
try{ localStorage.setItem('loki-files-open', FILES_OPEN ? '1' : '0'); }catch(_){}
// Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` :
// la gouttière de barre de défilement mesurée dans --sbw resterait celle de
// l'ancienne largeur, et la carte de saisie serait décalée du fil. La hauteur,
// elle, est suivie par un ResizeObserver et n'a besoin de rien.
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
if(FILES_OPEN) loadFiles();
}
function renderNode(r){
nodeList = (r && r.nodes) || [];
nodeTarget = (r && r.target) || '';
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
updateNodeBtn();
const list = document.getElementById('node-hub-list');
// Restaure l'état à l'ouverture de la page. Sur petit écran le panneau est un
// tiroir qui masque toute la conversation : on ne le rouvre jamais tout seul.
function initFiles(){
let want = false;
try{ want = localStorage.getItem('loki-files-open') === '1'; }catch(_){}
toggleFiles(want && window.innerWidth > 720);
}
async function loadFiles(dir){
if(dir !== undefined) FILES_DIR = dir;
const list = document.getElementById('files-list');
const foot = document.getElementById('files-foot');
if(!list) return;
let r;
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)); }
catch(_){ filesMsg('lecture impossible'); return; }
if(!r.ok){
// Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre
// onglet) ne doit pas laisser le panneau bloqué : on remonte à la racine.
if(FILES_DIR){ FILES_DIR = ''; return loadFiles(); }
filesMsg(r.error || 'lecture impossible'); return;
}
renderCrumb(r);
list.textContent = '';
if(!r.entries.length){
const d = document.createElement('div');
d.className = 'files-empty muted';
d.textContent = FILES_DIR ? '(dossier vide)' : "L'agent n'a encore rien écrit ici.";
list.appendChild(d);
}
for(const e of r.entries) list.appendChild(fileRow(e));
foot.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
foot.title = r.root || '';
}
function filesMsg(txt){
const list = document.getElementById('files-list');
if(!list) return;
list.textContent = '';
if(!nodeList.length){
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 d = document.createElement('div');
d.className = 'files-empty'; d.style.color = 'var(--err)'; d.textContent = txt;
list.appendChild(d);
}
// Fil d'Ariane : « travail / captures / <id> », chaque segment cliquable.
function renderCrumb(r){
const c = document.getElementById('files-crumb');
c.textContent = '';
const seg = (label, dir, last)=>{
const b = document.createElement('button');
b.className = 'files-crumb-b' + (last ? ' on' : '');
b.textContent = label;
if(!last) b.onclick = ()=>loadFiles(dir);
else b.disabled = true;
c.appendChild(b);
};
const parts = r.dir ? r.dir.split('/') : [];
seg('travail', '', parts.length === 0);
let acc = '';
parts.forEach((p, i)=>{
acc = acc ? acc + '/' + p : p;
const sep = document.createElement('span');
sep.className = 'files-crumb-s'; sep.textContent = '/';
c.appendChild(sep);
seg(p, acc, i === parts.length - 1);
});
}
function fileRow(e){
const row = document.createElement('div');
row.className = 'files-row' + (e.dir ? ' isdir' : '');
const ic = document.createElement('span');
ic.className = 'files-ic';
ic.textContent = e.dir ? '▸' : (e.image ? '▣' : '·');
row.appendChild(ic);
const mid = document.createElement('span');
mid.className = 'files-mid';
const nm = document.createElement('span');
nm.className = 'files-name';
nm.textContent = e.name;
nm.title = e.name;
const meta = document.createElement('span');
meta.className = 'files-meta';
meta.textContent = fmtSize(e.size) + (e.dir && e.items ? ' · ' + e.items + ' fich.' : '') + ' · ' + fmtWhen(e.mod);
mid.append(nm, meta);
row.appendChild(mid);
// Un dossier s'ouvre ; un fichier se télécharge. Le clic sur la ligne fait
// l'action évidente, les boutons ne servent qu'au reste.
if(e.dir){
mid.onclick = ()=>loadFiles(e.path);
mid.style.cursor = 'pointer';
} else {
mid.onclick = ()=>downloadWorkspaceFile(e.path, e.name, row);
mid.style.cursor = 'pointer';
}
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=>{
const row = document.createElement('div'); row.className = 'mcp-row'+(n.connected?'':' off');
const dot = document.createElement('span'); dot.className = 'mcp-dot '+(n.connected?'mcp-dot-ok':'mcp-dot-off');
dot.title = n.connected ? 'connecté' : 'hors ligne';
const info = document.createElement('div'); info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||'';
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os);
const caps = document.createElement('span'); caps.className = 'mcp-tools';
caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité';
meta.appendChild(caps);
if(n.root){ const rt = document.createElement('span'); rt.className='mcp-tools'; rt.textContent = '📁 '+n.root; rt.title=n.root; meta.appendChild(rt); }
info.appendChild(nm); info.appendChild(meta);
const edit = document.createElement('button'); edit.className='mcp-edit'; edit.title='régler'; edit.textContent='✎';
edit.onclick = (e)=>{ e.stopPropagation(); openNodeEdit(n.id); };
row.appendChild(dot); row.appendChild(info); row.appendChild(edit);
row.onclick = ()=>openNodeEdit(n.id);
list.appendChild(row);
});
}
// Sélecteur « L'IA agit sur : Ce serveur / <poste connecté> ». Les mêmes outils
// (bash/write/edit) s'exécutent sur la machine choisie ; un poste hors ligne ne
// peut pas être cible (fail-closed côté serveur).
function renderNodeTarget(list, connected){
const wrap = document.createElement('div'); wrap.className = 'node-target';
const lab = document.createElement('div'); lab.className = 'node-target-l';
lab.textContent = "L'IA agit sur :"; wrap.appendChild(lab);
const opts = [{slug:'', name:'Ce serveur'}].concat(nodeList.filter(n=>n.connected).map(n=>({slug:n.slug, name:n.name})));
// Cible sélectionnée mais hors ligne : on l'ajoute quand même, marquée, pour ne
// pas la perdre silencieusement.
if(nodeTarget && !opts.some(o=>o.slug===nodeTarget)){
const n = nodeList.find(x=>x.slug===nodeTarget);
opts.push({slug:nodeTarget, name:(n?n.name:nodeTarget)+' (hors ligne)', off:true});
const acts = document.createElement('span');
acts.className = 'files-acts';
if(!e.dir){
const dl = document.createElement('button');
dl.className = 'iconbtn'; dl.textContent = '↓'; dl.title = 'télécharger';
dl.onclick = ev=>{ ev.stopPropagation(); downloadWorkspaceFile(e.path, e.name, row); };
acts.appendChild(dl);
}
opts.forEach(o=>{
const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':'');
const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget;
rb.onchange = ()=>setNodeTarget(o.slug);
const sp = document.createElement('span'); sp.textContent = o.name;
l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l);
});
list.appendChild(wrap);
const rm = document.createElement('button');
rm.className = 'iconbtn'; rm.textContent = '🗑'; rm.title = 'supprimer';
rm.onclick = ev=>{ ev.stopPropagation(); removeFile(e); };
acts.appendChild(rm);
row.appendChild(acts);
return row;
}
async function setNodeTarget(slug){
const r = await jpost('/api/node/target', {slug});
nodeTarget = (r && r.target) || '';
toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur');
loadNode();
// Suppression. Toujours confirmée, et l'invite dit ce qui part : un dossier
// emporte tout son contenu, ce qu'un nom seul ne laisse pas deviner.
async function removeFile(e){
const quoi = e.dir
? 'Le dossier « '+e.name+' » et tout son contenu ('+(e.items||0)+' fichiers, '+fmtSize(e.size)+') seront supprimés.'
: '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
{title:'Supprimer ?', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/chat/file/delete', {path: e.path});
if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
loadFiles();
}
// ── Appairage : génère un code que le poste échange contre sa clé ────────────
function nodeCapCheckboxes(containerId, selected){
const box = document.getElementById(containerId);
box.textContent = '';
const sel = new Set(selected||[]);
nodeAllCaps.forEach(c=>{
const lab = document.createElement('label'); lab.className='node-cap';
const cb = document.createElement('input'); cb.type='checkbox'; cb.value=c; cb.checked = sel.has(c);
const sp = document.createElement('span'); sp.textContent = nodeCapLabels[c]||c;
lab.appendChild(cb); lab.appendChild(sp);
box.appendChild(lab);
});
}
function nodeCapValues(containerId){
return [...document.querySelectorAll('#'+containerId+' input:checked')].map(cb=>cb.value);
}
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
document.addEventListener('DOMContentLoaded', initFiles);
function openNodePair(){
nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent
document.getElementById('node-pair-root').value = '';
document.getElementById('node-pair-out').style.display = 'none';
showModal('node-pair-modal');
}
async function genNodeCode(){
const caps = nodeCapValues('node-pair-caps');
if(!caps.length){ toast('choisis au moins une capacité'); return; }
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/pair', {caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
document.getElementById('node-pair-code').textContent = r.code;
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : '';
// Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
// bout, le relais reste aveugle. --key = clé publique de l'agent (le poste
// scelle vers elle), --machine = quelle machine joindre.
const remote = 'loki remote install https://ajean.link --machine '+r.machine+
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
// Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais).
const lan = 'loki remote install '+location.origin+
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
document.getElementById('node-pair-cmd').textContent = remote;
const lanEl = document.getElementById('node-pair-cmd-lan');
if(lanEl) lanEl.textContent = lan;
document.getElementById('node-pair-out').style.display = '';
loadNode();
}
// ── Édition d'un poste appairé : capacités, dossier, révocation ──────────────
let nodeEditing = null;
function openNodeEdit(id){
const n = nodeList.find(x=>x.id===id); if(!n) return;
nodeEditing = id;
nodeCapCheckboxes('node-pair-caps', n.caps);
document.getElementById('node-pair-root').value = n.root||'';
document.getElementById('node-pair-out').style.display = 'none';
// Réutilise la modale d'appairage en mode édition : on remplace le pied.
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Poste « '+n.name+' »'+(n.connected?' (connecté)':'');
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '';
const cancel = document.createElement('button'); cancel.textContent='fermer'; cancel.onclick=()=>{ hideModal('node-pair-modal'); resetNodeFoot(); };
const save = document.createElement('button'); save.className='btn-save'; save.textContent='enregistrer'; save.onclick=saveNodeCaps;
footR.appendChild(cancel); footR.appendChild(save);
const footL = document.querySelector('#node-pair-modal .foot-l');
footL.innerHTML = '';
const del = document.createElement('button'); del.className='btn-danger'; del.textContent='révoquer'; del.onclick=()=>revokeNode(id);
footL.appendChild(del);
showModal('node-pair-modal');
}
function resetNodeFoot(){
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Appairer un poste distant';
document.querySelector('#node-pair-modal .foot-l').innerHTML = '';
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '<button onclick="hideModal(\'node-pair-modal\')">fermer</button><button class="btn-save" onclick="genNodeCode()">générer le code</button>';
nodeEditing = null;
}
async function saveNodeCaps(){
if(!nodeEditing) return;
const caps = nodeCapValues('node-pair-caps');
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/caps', {id:nodeEditing, caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
toast('poste mis à jour'); hideModal('node-pair-modal'); resetNodeFoot(); loadNode();
}
async function revokeNode(id){
const n = nodeList.find(x=>x.id===id);
if(!await askConfirm('Révoquer le poste « '+(n?n.name:'')+' » ? Sa clé sera oubliée et il sera déconnecté.', {title:'Révoquer le poste ?', okText:'Révoquer', danger:true})) return;
await jpost('/api/node/revoke', {id});
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
function fmtWhen(sec){
if(!sec) return '';
const d = new Date(sec*1000), now = new Date();
const memeJour = d.toDateString() === now.toDateString();
return memeJour
? d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'})
: d.toLocaleDateString(undefined, {day:'2-digit', month:'2-digit'});
}
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
//
+23 -59
View File
@@ -361,12 +361,11 @@
<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>
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
gabarit que le trombone — ce sont les deux faces du même sujet,
ce qui entre et ce qui sort. -->
<button id="files-btn" onclick="toggleFiles()" title="Fichiers du dossier de travail" aria-label="Fichiers du dossier de travail" aria-expanded="false">
<svg 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="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></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">
@@ -388,6 +387,24 @@
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div>
</div>
<!-- Fichiers : ce que l'agent a écrit dans son dossier de travail. Panneau de
DROITE, symétrique des réglages à gauche, ouvert et fermé par le bouton
dossier du pied de carte. Il ne se remplit qu'à l'ouverture (loadFiles) —
un ReadDir à chaque chargement de page pour un panneau fermé serait payé
par tout le monde et utile à personne. -->
<div id="files-backdrop" onclick="toggleFiles()"></div>
<aside id="files" aria-label="Fichiers du dossier de travail">
<div class="files-head">
<span class="stitle">Fichiers</span>
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
</div>
<!-- Fil d'Ariane : le dossier de travail contient des sous-dossiers (captures
par discussion), il faut pouvoir y descendre et en remonter. -->
<div id="files-crumb" class="files-crumb"></div>
<div id="files-list" class="files-list"></div>
<div id="files-foot" class="files-foot muted"></div>
</aside>
<div id="toast"></div>
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
@@ -568,59 +585,6 @@
</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">
<strong>Appairer un poste distant</strong>
<button class="modal-x" onclick="hideModal('node-pair-modal')" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="fld">
<label class="fld-l">Capacités autorisées <span class="opt">(ce que l'IA pourra faire sur ce poste)</span></label>
<div id="node-pair-caps" class="node-caps"></div>
</div>
<div class="fld">
<label class="fld-l" for="node-pair-root">Dossier autorisé <span class="opt">(confine lecture/écriture ; laisser vide = dossier de travail par défaut du poste)</span></label>
<input id="node-pair-root" class="ctl" placeholder="C:\\Users\\moi\\loki ou /home/moi/loki" spellcheck="false">
</div>
<div id="node-pair-out" style="display:none">
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5">
Sur le PC à piloter, installe <b>loki</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
<pre class="node-cmd" id="node-pair-cmd"></pre>
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="hideModal('node-pair-modal')">fermer</button>
<button class="btn-save" onclick="genNodeCode()">générer le code</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
+7 -4
View File
@@ -488,10 +488,13 @@ async function clearCrawlKey(){
toast('clé retirée');
}
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(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
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); }
+175
View File
@@ -0,0 +1,175 @@
// ─── Fichiers du dossier de travail ──────────────────────────────────────────
// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici
// on ne pouvait récupérer un fichier que si le modèle avait pensé à en mettre le
// lien dans sa réponse — tout le reste restait invisible, et faire le ménage
// demandait un shell.
//
// Le panneau est construit EN DOM, jamais en innerHTML : les noms de fichiers
// viennent du modèle et du système de fichiers, donc d'ailleurs. Même règle que
// pour les titres de discussions (16-conversations.js).
//
// Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
// déjà gérer le blob et les cas particuliers du navigateur.
let FILES_DIR = ''; // dossier affiché, relatif au dossier de travail
let FILES_OPEN = false;
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde
// souvent le panneau ouvert, et le rouvrir à chaque rechargement est pénible.
function toggleFiles(force){
FILES_OPEN = (force === undefined) ? !FILES_OPEN : !!force;
document.documentElement.setAttribute('data-files', FILES_OPEN ? '1' : '0');
const btn = document.getElementById('files-btn');
if(btn) btn.setAttribute('aria-expanded', FILES_OPEN ? 'true' : 'false');
try{ localStorage.setItem('loki-files-open', FILES_OPEN ? '1' : '0'); }catch(_){}
// Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` :
// la gouttière de barre de défilement mesurée dans --sbw resterait celle de
// l'ancienne largeur, et la carte de saisie serait décalée du fil. La hauteur,
// elle, est suivie par un ResizeObserver et n'a besoin de rien.
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
if(FILES_OPEN) loadFiles();
}
// Restaure l'état à l'ouverture de la page. Sur petit écran le panneau est un
// tiroir qui masque toute la conversation : on ne le rouvre jamais tout seul.
function initFiles(){
let want = false;
try{ want = localStorage.getItem('loki-files-open') === '1'; }catch(_){}
toggleFiles(want && window.innerWidth > 720);
}
async function loadFiles(dir){
if(dir !== undefined) FILES_DIR = dir;
const list = document.getElementById('files-list');
const foot = document.getElementById('files-foot');
if(!list) return;
let r;
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)); }
catch(_){ filesMsg('lecture impossible'); return; }
if(!r.ok){
// Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre
// onglet) ne doit pas laisser le panneau bloqué : on remonte à la racine.
if(FILES_DIR){ FILES_DIR = ''; return loadFiles(); }
filesMsg(r.error || 'lecture impossible'); return;
}
renderCrumb(r);
list.textContent = '';
if(!r.entries.length){
const d = document.createElement('div');
d.className = 'files-empty muted';
d.textContent = FILES_DIR ? '(dossier vide)' : "L'agent n'a encore rien écrit ici.";
list.appendChild(d);
}
for(const e of r.entries) list.appendChild(fileRow(e));
foot.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
foot.title = r.root || '';
}
function filesMsg(txt){
const list = document.getElementById('files-list');
if(!list) return;
list.textContent = '';
const d = document.createElement('div');
d.className = 'files-empty'; d.style.color = 'var(--err)'; d.textContent = txt;
list.appendChild(d);
}
// Fil d'Ariane : « travail / captures / <id> », chaque segment cliquable.
function renderCrumb(r){
const c = document.getElementById('files-crumb');
c.textContent = '';
const seg = (label, dir, last)=>{
const b = document.createElement('button');
b.className = 'files-crumb-b' + (last ? ' on' : '');
b.textContent = label;
if(!last) b.onclick = ()=>loadFiles(dir);
else b.disabled = true;
c.appendChild(b);
};
const parts = r.dir ? r.dir.split('/') : [];
seg('travail', '', parts.length === 0);
let acc = '';
parts.forEach((p, i)=>{
acc = acc ? acc + '/' + p : p;
const sep = document.createElement('span');
sep.className = 'files-crumb-s'; sep.textContent = '/';
c.appendChild(sep);
seg(p, acc, i === parts.length - 1);
});
}
function fileRow(e){
const row = document.createElement('div');
row.className = 'files-row' + (e.dir ? ' isdir' : '');
const ic = document.createElement('span');
ic.className = 'files-ic';
ic.textContent = e.dir ? '▸' : (e.image ? '▣' : '·');
row.appendChild(ic);
const mid = document.createElement('span');
mid.className = 'files-mid';
const nm = document.createElement('span');
nm.className = 'files-name';
nm.textContent = e.name;
nm.title = e.name;
const meta = document.createElement('span');
meta.className = 'files-meta';
meta.textContent = fmtSize(e.size) + (e.dir && e.items ? ' · ' + e.items + ' fich.' : '') + ' · ' + fmtWhen(e.mod);
mid.append(nm, meta);
row.appendChild(mid);
// Un dossier s'ouvre ; un fichier se télécharge. Le clic sur la ligne fait
// l'action évidente, les boutons ne servent qu'au reste.
if(e.dir){
mid.onclick = ()=>loadFiles(e.path);
mid.style.cursor = 'pointer';
} else {
mid.onclick = ()=>downloadWorkspaceFile(e.path, e.name, row);
mid.style.cursor = 'pointer';
}
const acts = document.createElement('span');
acts.className = 'files-acts';
if(!e.dir){
const dl = document.createElement('button');
dl.className = 'iconbtn'; dl.textContent = '↓'; dl.title = 'télécharger';
dl.onclick = ev=>{ ev.stopPropagation(); downloadWorkspaceFile(e.path, e.name, row); };
acts.appendChild(dl);
}
const rm = document.createElement('button');
rm.className = 'iconbtn'; rm.textContent = '🗑'; rm.title = 'supprimer';
rm.onclick = ev=>{ ev.stopPropagation(); removeFile(e); };
acts.appendChild(rm);
row.appendChild(acts);
return row;
}
// Suppression. Toujours confirmée, et l'invite dit ce qui part : un dossier
// emporte tout son contenu, ce qu'un nom seul ne laisse pas deviner.
async function removeFile(e){
const quoi = e.dir
? 'Le dossier « '+e.name+' » et tout son contenu ('+(e.items||0)+' fichiers, '+fmtSize(e.size)+') seront supprimés.'
: '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
{title:'Supprimer ?', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/chat/file/delete', {path: e.path});
if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
loadFiles();
}
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
document.addEventListener('DOMContentLoaded', initFiles);
function fmtWhen(sec){
if(!sec) return '';
const d = new Date(sec*1000), now = new Date();
const memeJour = d.toDateString() === now.toDateString();
return memeJour
? d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'})
: d.toLocaleDateString(undefined, {day:'2-digit', month:'2-digit'});
}
-187
View File
@@ -1,187 +0,0 @@
// ─── Postes distants ─────────────────────────────────────────────────────────
// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que
// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit
// les capacités autorisées et le dossier racine, puis peut lister/révoquer les
// postes. Sous-réglage du mode agent (grisé quand l'agent est off).
let nodeList = [];
let nodeTarget = '';
let nodeAllCaps = ['shell','read','write','list'];
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;
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 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=>{
const row = document.createElement('div'); row.className = 'mcp-row'+(n.connected?'':' off');
const dot = document.createElement('span'); dot.className = 'mcp-dot '+(n.connected?'mcp-dot-ok':'mcp-dot-off');
dot.title = n.connected ? 'connecté' : 'hors ligne';
const info = document.createElement('div'); info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||'';
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os);
const caps = document.createElement('span'); caps.className = 'mcp-tools';
caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité';
meta.appendChild(caps);
if(n.root){ const rt = document.createElement('span'); rt.className='mcp-tools'; rt.textContent = '📁 '+n.root; rt.title=n.root; meta.appendChild(rt); }
info.appendChild(nm); info.appendChild(meta);
const edit = document.createElement('button'); edit.className='mcp-edit'; edit.title='régler'; edit.textContent='✎';
edit.onclick = (e)=>{ e.stopPropagation(); openNodeEdit(n.id); };
row.appendChild(dot); row.appendChild(info); row.appendChild(edit);
row.onclick = ()=>openNodeEdit(n.id);
list.appendChild(row);
});
}
// Sélecteur « L'IA agit sur : Ce serveur / <poste connecté> ». Les mêmes outils
// (bash/write/edit) s'exécutent sur la machine choisie ; un poste hors ligne ne
// peut pas être cible (fail-closed côté serveur).
function renderNodeTarget(list, connected){
const wrap = document.createElement('div'); wrap.className = 'node-target';
const lab = document.createElement('div'); lab.className = 'node-target-l';
lab.textContent = "L'IA agit sur :"; wrap.appendChild(lab);
const opts = [{slug:'', name:'Ce serveur'}].concat(nodeList.filter(n=>n.connected).map(n=>({slug:n.slug, name:n.name})));
// Cible sélectionnée mais hors ligne : on l'ajoute quand même, marquée, pour ne
// pas la perdre silencieusement.
if(nodeTarget && !opts.some(o=>o.slug===nodeTarget)){
const n = nodeList.find(x=>x.slug===nodeTarget);
opts.push({slug:nodeTarget, name:(n?n.name:nodeTarget)+' (hors ligne)', off:true});
}
opts.forEach(o=>{
const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':'');
const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget;
rb.onchange = ()=>setNodeTarget(o.slug);
const sp = document.createElement('span'); sp.textContent = o.name;
l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l);
});
list.appendChild(wrap);
}
async function setNodeTarget(slug){
const r = await jpost('/api/node/target', {slug});
nodeTarget = (r && r.target) || '';
toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur');
loadNode();
}
// ── Appairage : génère un code que le poste échange contre sa clé ────────────
function nodeCapCheckboxes(containerId, selected){
const box = document.getElementById(containerId);
box.textContent = '';
const sel = new Set(selected||[]);
nodeAllCaps.forEach(c=>{
const lab = document.createElement('label'); lab.className='node-cap';
const cb = document.createElement('input'); cb.type='checkbox'; cb.value=c; cb.checked = sel.has(c);
const sp = document.createElement('span'); sp.textContent = nodeCapLabels[c]||c;
lab.appendChild(cb); lab.appendChild(sp);
box.appendChild(lab);
});
}
function nodeCapValues(containerId){
return [...document.querySelectorAll('#'+containerId+' input:checked')].map(cb=>cb.value);
}
function openNodePair(){
nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent
document.getElementById('node-pair-root').value = '';
document.getElementById('node-pair-out').style.display = 'none';
showModal('node-pair-modal');
}
async function genNodeCode(){
const caps = nodeCapValues('node-pair-caps');
if(!caps.length){ toast('choisis au moins une capacité'); return; }
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/pair', {caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
document.getElementById('node-pair-code').textContent = r.code;
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : '';
// Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
// bout, le relais reste aveugle. --key = clé publique de l'agent (le poste
// scelle vers elle), --machine = quelle machine joindre.
const remote = 'loki remote install https://ajean.link --machine '+r.machine+
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
// Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais).
const lan = 'loki remote install '+location.origin+
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
document.getElementById('node-pair-cmd').textContent = remote;
const lanEl = document.getElementById('node-pair-cmd-lan');
if(lanEl) lanEl.textContent = lan;
document.getElementById('node-pair-out').style.display = '';
loadNode();
}
// ── Édition d'un poste appairé : capacités, dossier, révocation ──────────────
let nodeEditing = null;
function openNodeEdit(id){
const n = nodeList.find(x=>x.id===id); if(!n) return;
nodeEditing = id;
nodeCapCheckboxes('node-pair-caps', n.caps);
document.getElementById('node-pair-root').value = n.root||'';
document.getElementById('node-pair-out').style.display = 'none';
// Réutilise la modale d'appairage en mode édition : on remplace le pied.
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Poste « '+n.name+' »'+(n.connected?' (connecté)':'');
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '';
const cancel = document.createElement('button'); cancel.textContent='fermer'; cancel.onclick=()=>{ hideModal('node-pair-modal'); resetNodeFoot(); };
const save = document.createElement('button'); save.className='btn-save'; save.textContent='enregistrer'; save.onclick=saveNodeCaps;
footR.appendChild(cancel); footR.appendChild(save);
const footL = document.querySelector('#node-pair-modal .foot-l');
footL.innerHTML = '';
const del = document.createElement('button'); del.className='btn-danger'; del.textContent='révoquer'; del.onclick=()=>revokeNode(id);
footL.appendChild(del);
showModal('node-pair-modal');
}
function resetNodeFoot(){
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Appairer un poste distant';
document.querySelector('#node-pair-modal .foot-l').innerHTML = '';
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '<button onclick="hideModal(\'node-pair-modal\')">fermer</button><button class="btn-save" onclick="genNodeCode()">générer le code</button>';
nodeEditing = null;
}
async function saveNodeCaps(){
if(!nodeEditing) return;
const caps = nodeCapValues('node-pair-caps');
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/caps', {id:nodeEditing, caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
toast('poste mis à jour'); hideModal('node-pair-modal'); resetNodeFoot(); loadNode();
}
async function revokeNode(id){
const n = nodeList.find(x=>x.id===id);
if(!await askConfirm('Révoquer le poste « '+(n?n.name:'')+' » ? Sa clé sera oubliée et il sera déconnecté.', {title:'Révoquer le poste ?', okText:'Révoquer', danger:true})) return;
await jpost('/api/node/revoke', {id});
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
}
+69 -29
View File
@@ -1114,20 +1114,6 @@ 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
@@ -1243,21 +1229,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
/* Postes distants : cases de capacités, code d'appairage, commande. */
.node-caps{display:flex;flex-wrap:wrap;gap:6px}
.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none}
.node-cap input{margin:0}
.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0}
.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px}
/* Poste distant : sélecteur de cible d'exécution. */
.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px}
.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%}
.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer}
.node-target-opt.on{border-color:var(--accent);color:var(--accent)}
.node-target-opt.off{opacity:.6}
.node-target-opt input{margin:0}
/* ===== Direction « Terre » : surfaces en cartes =======================
La charte pose des CARTES arrondies séparées par une gouttière, au lieu
d'une colonne à filets. Bloc en fin de feuille pour prendre le pas sur les
@@ -1394,3 +1365,72 @@ button:hover{border-color:var(--dim);color:var(--text)}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default}
/* --- Panneau « Fichiers » (droite) ------------------------------------
Symétrique des réglages à gauche : une colonne fixe qui pousse la
conversation au lieu de la recouvrir. C'est possible ici parce que la largeur
de lecture est pilotée par --measure et que #chat se recentre tout seul dans
la place qui reste — le fil se resserre, il ne passe pas dessous.
Le panneau est retiré du flux quand il est fermé (et non simplement décalé) :
sinon il conserverait sa gouttière et le fil resterait décentré. */
#files{display:none;width:320px;flex-shrink:0;border-left:1px solid var(--border);
background:var(--panel);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));
overflow-y:auto;flex-direction:column;gap:10px}
html[data-files="1"] #files{display:flex}
#files-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:14}
.files-head{display:flex;align-items:center;gap:8px}
.files-head .stitle{flex:1}
#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:8px}
#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
discussion dans le chemin dépasse largement 320 px. */
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px}
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:7px;
background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
.files-crumb-b.on{color:var(--text);cursor:default}
.files-crumb-s{color:var(--dim);opacity:.5}
.files-list{display:flex;flex-direction:column;flex:1;min-height:0}
.files-empty{font-size:12px;line-height:1.5;padding:10px 2px}
/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
survol : au repos on veut lire des noms de fichiers, pas une grille de
boutons. Sur écran tactile (pas de survol) elles restent visibles. */
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;font-size:12px}
.files-row:hover{background:var(--row-bg-on)}
.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
.files-row.isdir .files-ic{color:var(--accent)}
.files-mid{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
.files-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.files-meta{font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
.files-acts{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .12s}
.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
@media (hover:none){.files-acts{opacity:1}}
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border)}
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
.files-row.busy{opacity:.55;pointer-events:none}
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand
le panneau est ouvert — l'état se lit sans regarder à droite. */
#files-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)}
#files-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#files-btn:active{transform:scale(.9)}
#files-btn svg{display:block}
html[data-files="1"] #files-btn{color:var(--accent)}
/* Petit écran : tiroir par la droite, comme les réglages par la gauche. Pousser
une colonne de 320 px sur un téléphone ne laisserait rien à la conversation. */
@media (max-width:720px){
#files{position:fixed;inset:0 0 0 auto;width:85%;max-width:340px;z-index:15;
background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));
padding-right:calc(16px + env(safe-area-inset-right))}
html[data-files="1"] #files-backdrop{display:block}
}
@media (min-width:721px){
/* Dans la charte « Terre », le panneau est une carte comme les autres. */
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
box-shadow:var(--shadow);margin:0}
}
+219
View File
@@ -0,0 +1,219 @@
package loki
// web_files.go — parcourir et supprimer les fichiers produits par l'agent.
//
// L'agent écrit dans son dossier de travail (chat_workspace.go) : rapports,
// scripts, exports, captures d'écran. Jusqu'ici on ne pouvait récupérer un
// fichier que si le modèle en avait mis le lien dans sa réponse — tout ce qu'il
// produisait sans le dire restait invisible, et rien ne permettait de faire le
// ménage sans passer par un shell.
//
// Le téléchargement existait déjà (handleChatFile, web_upload.go). Ce fichier
// ajoute les deux verbes qui manquaient : LISTER et SUPPRIMER.
//
// Tout est borné par workspaceRel (web_upload.go), qui résout les liens
// symboliques des DEUX côtés avant de comparer : un lien posé dans le dossier
// de travail ne peut pas servir d'échappatoire vers le reste du disque.
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
// wsEntry — une ligne du panneau. Pour un dossier, Size est la somme récursive
// de son contenu : c'est ce qu'on libère en le supprimant, donc c'est ce chiffre
// qui aide à décider.
type wsEntry struct {
Name string `json:"name"`
Path string `json:"path"` // relatif au dossier de travail, séparateurs /
Dir bool `json:"dir"`
Size int64 `json:"size"`
Mod int64 `json:"mod"` // date de modification, epoch en secondes
Image bool `json:"image"` // affichable via /api/chat/image
Items int `json:"items,omitempty"`
}
// wsImageExt : extensions présentées comme images dans le panneau. Simple
// indice d'affichage — /api/chat/image renifle le CONTENU avant de servir quoi
// que ce soit, donc un .png qui contient du HTML est refusé là-bas, pas ici.
var wsImageExt = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".webp": true, ".bmp": true, ".svg": true, ".avif": true,
}
// wsWalkLimit borne le parcours récursif qui calcule la taille d'un dossier.
// Un workspace part de zéro et grossit lentement, mais rien n'empêche l'agent
// d'y dézipper 200 000 fichiers : la liste doit rester instantanée, quitte à
// annoncer une taille approchée (Items le dit).
const wsWalkLimit = 20000
// wsResolve traduit un chemin RELATIF venu du client en chemin absolu vérifié.
// La chaîne vide et "." désignent la racine du dossier de travail — cas que
// workspaceRel rejette (il renvoie false sur "."), d'où ce sas.
func wsResolve(rel string) (string, bool) {
rel = strings.TrimSpace(rel)
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
if rel == "" || rel == "." {
return agentWorkspace(), true
}
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(abs); !ok {
return "", false
}
return abs, true
}
// dirSize additionne le contenu d'un dossier, en s'arrêtant à wsWalkLimit
// entrées. Renvoie aussi le nombre de fichiers rencontrés.
func dirSize(root string) (total int64, count int) {
var walk func(string)
walk = func(dir string) {
if count >= wsWalkLimit {
return
}
ents, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range ents {
if count >= wsWalkLimit {
return
}
// Un lien symbolique n'est pas suivi : sa cible ne nous appartient
// pas, et une boucle de liens ferait tourner ce parcours sans fin.
if e.Type()&os.ModeSymlink != 0 {
continue
}
if e.IsDir() {
walk(filepath.Join(dir, e.Name()))
continue
}
if info, err := e.Info(); err == nil {
total += info.Size()
count++
}
}
}
walk(root)
return total, count
}
// handleChatFiles liste un dossier du workspace.
//
// GET /api/chat/files?dir=<relatif>
// → {ok, dir, parent, entries:[…], total, count}
//
// `total` et `count` portent sur le dossier de travail ENTIER, pas sur le
// dossier affiché : c'est l'occupation disque de l'agent, l'information qu'on
// veut avoir sous les yeux en faisant le ménage.
func handleChatFiles(w http.ResponseWriter, r *http.Request) {
rel := r.URL.Query().Get("dir")
abs, ok := wsResolve(rel)
if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
}
st, err := os.Stat(abs)
if err != nil || !st.IsDir() {
sendJSON(w, 404, map[string]any{"ok": false, "error": "dossier introuvable"})
return
}
ents, err := os.ReadDir(abs)
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
cur, _ := workspaceRel(abs) // "" à la racine
entries := []wsEntry{}
for _, e := range ents {
name := e.Name()
info, err := e.Info()
if err != nil {
continue
}
p := name
if cur != "" {
p = cur + "/" + name
}
it := wsEntry{Name: name, Path: p, Dir: e.IsDir(), Mod: info.ModTime().Unix()}
if e.IsDir() {
it.Size, it.Items = dirSize(filepath.Join(abs, name))
} else {
it.Size = info.Size()
it.Image = wsImageExt[strings.ToLower(filepath.Ext(name))]
}
entries = append(entries, it)
}
// Dossiers d'abord, puis du plus récent au plus ancien : ce qu'on cherche
// après un tour d'agent, c'est ce qu'il vient d'écrire.
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].Dir != entries[j].Dir {
return entries[i].Dir
}
return entries[i].Mod > entries[j].Mod
})
parent := ""
if cur != "" {
if i := strings.LastIndexByte(cur, '/'); i >= 0 {
parent = cur[:i]
}
}
total, count := dirSize(agentWorkspace())
sendJSON(w, 200, map[string]any{
"ok": true, "dir": cur, "parent": parent, "at_root": cur == "",
"entries": entries, "total": total, "count": count,
"root": agentWorkspace(),
})
}
// handleChatFileDelete supprime un fichier ou un dossier du workspace.
//
// POST /api/chat/file/delete {"path":"rapport.md"}
// → {ok, freed}
//
// La racine du dossier de travail est refusée : « tout supprimer » n'est pas un
// geste qu'on veut à un clic de distance, et l'agent perdrait aussi le dossier
// dans lequel il écrit.
func handleChatFileDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if strings.TrimSpace(req.Path) == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return
}
abs, ok := wsResolve(req.Path)
if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
}
if abs == agentWorkspace() {
sendJSON(w, 403, map[string]any{"ok": false, "error": "le dossier de travail lui-même ne se supprime pas"})
return
}
st, err := os.Lstat(abs)
if err != nil {
sendJSON(w, 404, map[string]any{"ok": false, "error": "introuvable"})
return
}
var freed int64
if st.IsDir() {
freed, _ = dirSize(abs)
err = os.RemoveAll(abs)
} else {
freed = st.Size()
err = os.Remove(abs)
}
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed})
}
+232
View File
@@ -0,0 +1,232 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// withWorkspace pointe le dossier de travail sur un dossier temporaire. Le
// chemin est mémoïsé par un sync.Once (chat_workspace.go), donc on force la
// variable directement — c'est le seul moyen d'isoler ces tests.
func withWorkspace(t *testing.T) string {
t.Helper()
dir := t.TempDir()
// EvalSymlinks : sur macOS /var est un lien vers /private/var, et
// workspaceRel compare des chemins résolus. Sans ça tout serait « hors du
// dossier de travail ».
if r, err := filepath.EvalSymlinks(dir); err == nil {
dir = r
}
// On laisse d'abord agentWorkspace s'initialiser NORMALEMENT : sinon on
// consomme le sync.Once avec une valeur vide, et tout test ultérieur qui
// appelle agentWorkspace récupère "" après restauration — une panne à
// distance, dans un autre fichier, sans rapport visible avec celui-ci.
prevPath := agentWorkspace()
workspacePath = dir
t.Cleanup(func() { workspacePath = prevPath })
return dir
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func listFiles(t *testing.T, dir string) map[string]any {
t.Helper()
rec := httptest.NewRecorder()
handleChatFiles(rec, httptest.NewRequest("GET", "/api/chat/files?dir="+dir, nil))
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
}
out["_code"] = rec.Code
return out
}
func TestFilesListSortsAndSizes(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "rapport.md"), "bonjour")
writeFile(t, filepath.Join(ws, "captures", "conv1", "a.jpg"), "0123456789")
writeFile(t, filepath.Join(ws, "captures", "conv1", "b.jpg"), "01234")
out := listFiles(t, "")
if out["ok"] != true {
t.Fatalf("liste refusée : %v", out)
}
entries := out["entries"].([]any)
if len(entries) != 2 {
t.Fatalf("attendu 2 entrées à la racine, obtenu %d : %v", len(entries), entries)
}
// Les dossiers passent devant, quelle que soit leur date.
first := entries[0].(map[string]any)
if first["name"] != "captures" || first["dir"] != true {
t.Errorf("le dossier doit être en tête, obtenu %v", first)
}
// La taille d'un dossier est celle de TOUT son contenu : c'est ce qu'on
// libère en le supprimant, donc c'est le chiffre qui compte.
if got := first["size"].(float64); got != 15 {
t.Errorf("taille du dossier = %v, attendu 15 (10+5)", got)
}
if got := first["items"].(float64); got != 2 {
t.Errorf("items = %v, attendu 2", got)
}
// Le total porte sur le dossier de travail entier, pas sur le dossier affiché.
if got := out["total"].(float64); got != 22 {
t.Errorf("total = %v, attendu 22 (7+10+5)", got)
}
if out["at_root"] != true || out["parent"] != "" {
t.Errorf("racine mal signalée : %v", out)
}
}
func TestFilesListSubdirAndImageFlag(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "captures", "conv1", "shot.jpg"), "x")
writeFile(t, filepath.Join(ws, "captures", "conv1", "notes.txt"), "y")
out := listFiles(t, "captures/conv1")
if out["ok"] != true {
t.Fatalf("liste refusée : %v", out)
}
if out["dir"] != "captures/conv1" || out["parent"] != "captures" {
t.Errorf("fil d'Ariane cassé : dir=%v parent=%v", out["dir"], out["parent"])
}
img := map[string]bool{}
for _, e := range out["entries"].([]any) {
m := e.(map[string]any)
img[m["name"].(string)] = m["image"] == true
// Le chemin renvoyé doit être utilisable tel quel par /api/chat/file.
if !strings.HasPrefix(m["path"].(string), "captures/conv1/") {
t.Errorf("chemin non préfixé : %v", m["path"])
}
}
if !img["shot.jpg"] {
t.Error("shot.jpg devrait être marqué image")
}
if img["notes.txt"] {
t.Error("notes.txt ne devrait pas être marqué image")
}
}
// Le bornage est la partie qui compte : ces routes lisent et SUPPRIMENT sur
// disque à partir d'un chemin fourni par le navigateur.
func TestFilesRejectsEscapes(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "dedans.txt"), "ok")
secret := filepath.Join(filepath.Dir(ws), "dehors.txt")
writeFile(t, secret, "secret")
for _, bad := range []string{"..", "../", "../..", "/etc", "captures/../..", `..\..`} {
out := listFiles(t, bad)
if out["ok"] == true {
t.Errorf("dir=%q accepté alors qu'il sort du dossier de travail : %v", bad, out)
}
}
// Suppression : même bornage, et le fichier voisin doit survivre.
for _, bad := range []string{"../dehors.txt", "/etc/passwd", "..", ""} {
rec := httptest.NewRecorder()
body := strings.NewReader(`{"path":` + jsonQuote(bad) + `}`)
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete", body))
if rec.Code == http.StatusOK {
t.Errorf("suppression de %q acceptée : %s", bad, rec.Body.String())
}
}
if _, err := os.Stat(secret); err != nil {
t.Fatalf("le fichier hors workspace a été supprimé : %v", err)
}
}
// La racine elle-même ne se supprime pas : l'agent perdrait le dossier dans
// lequel il écrit, et « tout effacer » ne doit pas être à un clic de distance.
func TestFilesRootNotDeletable(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "a.txt"), "a")
for _, p := range []string{".", "./", "/"} {
rec := httptest.NewRecorder()
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
strings.NewReader(`{"path":`+jsonQuote(p)+`}`)))
if rec.Code == http.StatusOK {
t.Errorf("path=%q a supprimé la racine", p)
}
}
if _, err := os.Stat(ws); err != nil {
t.Fatalf("dossier de travail détruit : %v", err)
}
}
func TestFilesDeleteFileAndDir(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "rapport.md"), "bonjour")
writeFile(t, filepath.Join(ws, "captures", "conv1", "a.jpg"), "0123456789")
del := func(p string) map[string]any {
rec := httptest.NewRecorder()
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
strings.NewReader(`{"path":`+jsonQuote(p)+`}`)))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
out["_code"] = rec.Code
return out
}
r := del("rapport.md")
if r["ok"] != true || r["freed"].(float64) != 7 {
t.Errorf("suppression de fichier : %v", r)
}
if _, err := os.Stat(filepath.Join(ws, "rapport.md")); !os.IsNotExist(err) {
t.Error("le fichier est toujours là")
}
// Un dossier part avec tout son contenu — c'est ce que l'interface annonce.
r = del("captures")
if r["ok"] != true || r["freed"].(float64) != 10 {
t.Errorf("suppression de dossier : %v", r)
}
if _, err := os.Stat(filepath.Join(ws, "captures")); !os.IsNotExist(err) {
t.Error("le dossier est toujours là")
}
// Le dossier de travail, lui, doit avoir survécu.
if _, err := os.Stat(ws); err != nil {
t.Fatalf("dossier de travail détruit : %v", err)
}
}
// Un lien symbolique posé dans le dossier de travail ne doit pas servir de
// passage vers le reste du disque — ni en lecture, ni en suppression.
func TestFilesSymlinkEscape(t *testing.T) {
ws := withWorkspace(t)
outside := t.TempDir()
writeFile(t, filepath.Join(outside, "cible.txt"), "secret")
if err := os.Symlink(outside, filepath.Join(ws, "evasion")); err != nil {
t.Skipf("liens symboliques indisponibles : %v", err)
}
if out := listFiles(t, "evasion"); out["ok"] == true {
t.Errorf("le lien symbolique a été suivi : %v", out)
}
rec := httptest.NewRecorder()
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
strings.NewReader(`{"path":"evasion/cible.txt"}`)))
if rec.Code == http.StatusOK {
t.Errorf("suppression à travers le lien acceptée : %s", rec.Body.String())
}
if _, err := os.Stat(filepath.Join(outside, "cible.txt")); err != nil {
t.Fatalf("fichier hors workspace supprimé : %v", err)
}
}
func jsonQuote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
+9 -7
View File
@@ -217,13 +217,15 @@ func newWebMux() *http.ServeMux {
api("/api/restart", svcHandler("restart"))
api("/api/bench", handleBench)
api("/api/bench/last", handleBenchLast)
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
api("/api/chat/files", handleChatFiles) // liste un dossier du dossier de travail (panneau Fichiers)
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier du dossier de travail
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
// Discussions : liste, création, bascule, renommage, suppression.
api("/api/conversations", handleConvList)
api("/api/conversations/new", handleConvNew)