mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge pull request #5 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Claude/ajean loki container fork aep9r2
This commit is contained in:
10 files changed
+1088
-561
No files matched your search
@@ -7,9 +7,10 @@
|
|||||||
> **un conteneur Docker GPU autonome**, là où l'amont s'installe en binaire +
|
> **un conteneur Docker GPU autonome**, là où l'amont s'installe en binaire +
|
||||||
> systemd sur la machine hôte.
|
> systemd sur la machine hôte.
|
||||||
|
|
||||||
Loki fait tourner un modèle de langage **100 % en local** : tchat, mémoire
|
Loki fait tourner un modèle de langage **100 % en local** : discussions
|
||||||
persistante, accès internet, outils MCP, agent (shell, fichiers), accès distant
|
multiples, mémoire persistante, accès internet, captures de pages web, outils
|
||||||
chiffré — serveur d'inférence llama.cpp compris, dans une seule image.
|
MCP, agent (shell, fichiers) — serveur d'inférence llama.cpp compris, dans une
|
||||||
|
seule image.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ chiffré — serveur d'inférence llama.cpp compris, dans une seule image.
|
|||||||
│ │ pilote (fichier PID — pas de systemd) │
|
│ │ pilote (fichier PID — pas de systemd) │
|
||||||
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
||||||
│ ▲ modèles .gguf │
|
│ ▲ 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) │
|
│ /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_NGL` | couches GPU initiales | `999` (tout) |
|
||||||
| `LOKI_HOME` | données (volume) | `/data` |
|
| `LOKI_HOME` | données (volume) | `/data` |
|
||||||
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
|
| `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 :
|
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
`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
|
**Tout** l'état vit sous `/data` (= `LOKI_HOME`). Si ce chemin n'est pas un
|
||||||
(selon modèle), export de conversations.
|
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`).
|
- **Mémoire persistante** (`memory off|ondemand|always`).
|
||||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||||
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
|
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
|
||||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
- **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é).
|
- **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
|
## Différences avec l'amont
|
||||||
|
|
||||||
| | AJEAN (amont) | Loki (ce fork) |
|
| | AJEAN (amont) | Loki (ce fork) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` |
|
| 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 |
|
| 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`) |
|
| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) |
|
||||||
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
|
| 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` |
|
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
|
||||||
|
|
||||||
Le reste — UI, mémoire, outils, protocole — est celui d'AJEAN. Pour récupérer
|
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||||
les évolutions de l'amont :
|
Pour récupérer les évolutions de l'amont :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch upstream && git merge upstream/main # conflits de renommage à arbitrer
|
git fetch upstream && git merge upstream/main # conflits de renommage à arbitrer
|
||||||
|
|||||||
+259
-264
@@ -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:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||||||
#attach:active{transform:scale(.9)}
|
#attach:active{transform:scale(.9)}
|
||||||
#attach svg{display:block}
|
#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.
|
/* 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
|
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
|
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{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}
|
.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 =======================
|
/* ===== Direction « Terre » : surfaces en cartes =======================
|
||||||
La charte pose des CARTES arrondies séparées par une gouttière, au lieu
|
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
|
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;
|
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
|
||||||
color:var(--dim);cursor:pointer}
|
color:var(--dim);cursor:pointer}
|
||||||
.hf-mm.muted{cursor:default}
|
.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>
|
</style></head><body>
|
||||||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||||||
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
<!-- 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 style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||||
<span id="ctx-text" class="muted">0 / 0</span>
|
<span id="ctx-text" class="muted">0 / 0</span>
|
||||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||||
<!-- Postes distants : l'icône dit SUR QUOI l'IA agit. Écran = ce serveur
|
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
|
||||||
(cible locale) ; ondes wifi = un poste distant est la cible active.
|
gabarit que le trombone — ce sont les deux faces du même sujet,
|
||||||
Un clic ouvre la modale de gestion (cible, liste, appairage). -->
|
ce qui entre et ce qui sort. -->
|
||||||
<button id="node-btn" onclick="openNodeHub()" title="Postes distants" aria-label="Postes distants">
|
<button id="files-btn" onclick="toggleFiles()" title="Fichiers du dossier de travail" aria-label="Fichiers du dossier de travail" aria-expanded="false">
|
||||||
<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 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>
|
||||||
<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>
|
</button>
|
||||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
<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">
|
<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 id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||||
</div>
|
</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="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 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">
|
<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>
|
||||||
</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 id="modal" class="modal-ov" style="display:none">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
@@ -3214,10 +3218,13 @@ async function clearCrawlKey(){
|
|||||||
toast('clé retirée');
|
toast('clé retirée');
|
||||||
}
|
}
|
||||||
async function loadAll(){
|
async function loadAll(){
|
||||||
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
|
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
|
||||||
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||||
// hauteurs réservées en place pour toujours.
|
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
|
||||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
|
// 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
|
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); }
|
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);
|
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
|
||||||
}
|
}
|
||||||
renderAttach();
|
renderAttach();
|
||||||
// ─── Postes distants ─────────────────────────────────────────────────────────
|
// ─── Fichiers du dossier de travail ──────────────────────────────────────────
|
||||||
// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que
|
// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici
|
||||||
// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit
|
// on ne pouvait récupérer un fichier que si le modèle avait pensé à en mettre le
|
||||||
// les capacités autorisées et le dossier racine, puis peut lister/révoquer les
|
// lien dans sa réponse — tout le reste restait invisible, et faire le ménage
|
||||||
// postes. Sous-réglage du mode agent (grisé quand l'agent est off).
|
// demandait un shell.
|
||||||
let nodeList = [];
|
//
|
||||||
let nodeTarget = '';
|
// Le panneau est construit EN DOM, jamais en innerHTML : les noms de fichiers
|
||||||
let nodeAllCaps = ['shell','read','write','list'];
|
// viennent du modèle et du système de fichiers, donc d'ailleurs. Même règle que
|
||||||
const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister'};
|
// 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
|
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde
|
||||||
// par le bouton PC/wifi du composeur.
|
// souvent le panneau ouvert, et le rouvrir à chaque rechargement est pénible.
|
||||||
function openNodeHub(){ loadNode(); showModal('node-hub-modal'); }
|
function toggleFiles(force){
|
||||||
|
FILES_OPEN = (force === undefined) ? !FILES_OPEN : !!force;
|
||||||
// Le bouton du composeur reflète SUR QUOI l'IA agit : écran = ce serveur, ondes
|
document.documentElement.setAttribute('data-files', FILES_OPEN ? '1' : '0');
|
||||||
// wifi (teinté accent) = un poste distant est la cible active.
|
const btn = document.getElementById('files-btn');
|
||||||
function updateNodeBtn(){
|
if(btn) btn.setAttribute('aria-expanded', FILES_OPEN ? 'true' : 'false');
|
||||||
const btn = document.getElementById('node-btn');
|
try{ localStorage.setItem('loki-files-open', FILES_OPEN ? '1' : '0'); }catch(_){}
|
||||||
if(!btn) return;
|
// Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` :
|
||||||
const remote = !!nodeTarget;
|
// la gouttière de barre de défilement mesurée dans --sbw resterait celle de
|
||||||
btn.classList.toggle('remote', remote);
|
// l'ancienne largeur, et la carte de saisie serait décalée du fil. La hauteur,
|
||||||
const n = remote ? nodeList.find(x=>x.slug===nodeTarget) : null;
|
// elle, est suivie par un ResizeObserver et n'a besoin de rien.
|
||||||
btn.title = remote ? ('Postes distants — l\'IA agit sur « '+(n?n.name:nodeTarget)+' »') : 'Postes distants — l\'IA agit sur ce serveur';
|
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
|
||||||
|
if(FILES_OPEN) loadFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNode(r){
|
// Restaure l'état à l'ouverture de la page. Sur petit écran le panneau est un
|
||||||
nodeList = (r && r.nodes) || [];
|
// tiroir qui masque toute la conversation : on ne le rouvre jamais tout seul.
|
||||||
nodeTarget = (r && r.target) || '';
|
function initFiles(){
|
||||||
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
|
let want = false;
|
||||||
updateNodeBtn();
|
try{ want = localStorage.getItem('loki-files-open') === '1'; }catch(_){}
|
||||||
const list = document.getElementById('node-hub-list');
|
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;
|
if(!list) return;
|
||||||
list.textContent = '';
|
list.textContent = '';
|
||||||
if(!nodeList.length){
|
const d = document.createElement('div');
|
||||||
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste appairé — appaire un PC pour que l\'IA puisse agir dessus)</div>';
|
d.className = 'files-empty'; d.style.color = 'var(--err)'; d.textContent = txt;
|
||||||
return;
|
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 acts = document.createElement('span');
|
||||||
const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||'';
|
acts.className = 'files-acts';
|
||||||
const meta = document.createElement('div'); meta.className = 'mcp-meta';
|
if(!e.dir){
|
||||||
const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os);
|
const dl = document.createElement('button');
|
||||||
const caps = document.createElement('span'); caps.className = 'mcp-tools';
|
dl.className = 'iconbtn'; dl.textContent = '↓'; dl.title = 'télécharger';
|
||||||
caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité';
|
dl.onclick = ev=>{ ev.stopPropagation(); downloadWorkspaceFile(e.path, e.name, row); };
|
||||||
meta.appendChild(caps);
|
acts.appendChild(dl);
|
||||||
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 rm = document.createElement('button');
|
||||||
const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':'');
|
rm.className = 'iconbtn'; rm.textContent = '🗑'; rm.title = 'supprimer';
|
||||||
const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget;
|
rm.onclick = ev=>{ ev.stopPropagation(); removeFile(e); };
|
||||||
rb.onchange = ()=>setNodeTarget(o.slug);
|
acts.appendChild(rm);
|
||||||
const sp = document.createElement('span'); sp.textContent = o.name;
|
row.appendChild(acts);
|
||||||
l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l);
|
return row;
|
||||||
});
|
|
||||||
list.appendChild(wrap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setNodeTarget(slug){
|
// Suppression. Toujours confirmée, et l'invite dit ce qui part : un dossier
|
||||||
const r = await jpost('/api/node/target', {slug});
|
// emporte tout son contenu, ce qu'un nom seul ne laisse pas deviner.
|
||||||
nodeTarget = (r && r.target) || '';
|
async function removeFile(e){
|
||||||
toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur');
|
const quoi = e.dir
|
||||||
loadNode();
|
? '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é ────────────
|
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
|
||||||
function nodeCapCheckboxes(containerId, selected){
|
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
|
||||||
const box = document.getElementById(containerId);
|
document.addEventListener('DOMContentLoaded', initFiles);
|
||||||
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(){
|
function fmtWhen(sec){
|
||||||
nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent
|
if(!sec) return '';
|
||||||
document.getElementById('node-pair-root').value = '';
|
const d = new Date(sec*1000), now = new Date();
|
||||||
document.getElementById('node-pair-out').style.display = 'none';
|
const memeJour = d.toDateString() === now.toDateString();
|
||||||
showModal('node-pair-modal');
|
return memeJour
|
||||||
}
|
? d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'})
|
||||||
|
: d.toLocaleDateString(undefined, {day:'2-digit', month:'2-digit'});
|
||||||
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é');
|
|
||||||
}
|
}
|
||||||
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
|
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -361,12 +361,11 @@
|
|||||||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||||
<span id="ctx-text" class="muted">0 / 0</span>
|
<span id="ctx-text" class="muted">0 / 0</span>
|
||||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||||
<!-- Postes distants : l'icône dit SUR QUOI l'IA agit. Écran = ce serveur
|
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
|
||||||
(cible locale) ; ondes wifi = un poste distant est la cible active.
|
gabarit que le trombone — ce sont les deux faces du même sujet,
|
||||||
Un clic ouvre la modale de gestion (cible, liste, appairage). -->
|
ce qui entre et ce qui sort. -->
|
||||||
<button id="node-btn" onclick="openNodeHub()" title="Postes distants" aria-label="Postes distants">
|
<button id="files-btn" onclick="toggleFiles()" title="Fichiers du dossier de travail" aria-label="Fichiers du dossier de travail" aria-expanded="false">
|
||||||
<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 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>
|
||||||
<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>
|
</button>
|
||||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
<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">
|
<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 id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||||
</div>
|
</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="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 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">
|
<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>
|
||||||
</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 id="modal" class="modal-ov" style="display:none">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
|
|||||||
@@ -488,10 +488,13 @@ async function clearCrawlKey(){
|
|||||||
toast('clé retirée');
|
toast('clé retirée');
|
||||||
}
|
}
|
||||||
async function loadAll(){
|
async function loadAll(){
|
||||||
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
|
// allSettled et pas all : un seul chargement en échec (moteur arrêté, clé API
|
||||||
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
// absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||||
// hauteurs réservées en place pour toujours.
|
// hauteurs réservées en place pour toujours. ⚠️ Le revers : une fonction
|
||||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
|
// 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
|
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); }
|
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||||
@@ -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'});
|
||||||
|
}
|
||||||
@@ -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é');
|
|
||||||
}
|
|
||||||
@@ -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:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||||||
#attach:active{transform:scale(.9)}
|
#attach:active{transform:scale(.9)}
|
||||||
#attach svg{display:block}
|
#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.
|
/* 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
|
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
|
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{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}
|
.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 =======================
|
/* ===== Direction « Terre » : surfaces en cartes =======================
|
||||||
La charte pose des CARTES arrondies séparées par une gouttière, au lieu
|
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
|
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;
|
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
|
||||||
color:var(--dim);cursor:pointer}
|
color:var(--dim);cursor:pointer}
|
||||||
.hf-mm.muted{cursor:default}
|
.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}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -217,13 +217,15 @@ func newWebMux() *http.ServeMux {
|
|||||||
api("/api/restart", svcHandler("restart"))
|
api("/api/restart", svcHandler("restart"))
|
||||||
api("/api/bench", handleBench)
|
api("/api/bench", handleBench)
|
||||||
api("/api/bench/last", handleBenchLast)
|
api("/api/bench/last", handleBenchLast)
|
||||||
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
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/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/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/files", handleChatFiles) // liste un dossier du dossier de travail (panneau Fichiers)
|
||||||
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier du dossier de travail
|
||||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
||||||
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
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.
|
// Discussions : liste, création, bascule, renommage, suppression.
|
||||||
api("/api/conversations", handleConvList)
|
api("/api/conversations", handleConvList)
|
||||||
api("/api/conversations/new", handleConvNew)
|
api("/api/conversations/new", handleConvNew)
|
||||||
|
|||||||
Reference in new issue
Block a user