mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1075030dd | ||
|
|
c4e399e101 | ||
|
|
c32fd40f1a | ||
|
|
382c5dbdce | ||
|
|
fa69dac77f | ||
|
|
e9ec8ae3a8 | ||
|
|
74fb1fc781 | ||
|
|
91d1796615 | ||
|
|
00b2350fb9 |
No files matched your search
@@ -26,7 +26,26 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
|
||||
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
|
||||
patterns, table languages.json) est reprise
|
||||
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales.
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
|
||||
ajoutent depuis deux idées reprises d'OpenFox et réimplémentées ici : les
|
||||
**sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans son
|
||||
propre contexte) et le **fenêtrage du fil** au chargement d'une longue
|
||||
discussion.
|
||||
|
||||
## Reprises depuis l'amont après le fork
|
||||
|
||||
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
|
||||
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
|
||||
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
|
||||
présent dans l'image) et la **préparation des images** envoyées au modèle
|
||||
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
|
||||
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
|
||||
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
|
||||
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
|
||||
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -122,6 +122,8 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_HOME` | données (volume) | `/data` |
|
||||
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
|
||||
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
|
||||
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
|
||||
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
@@ -137,7 +139,9 @@ et modèles compris.
|
||||
| `/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/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
|
||||
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
|
||||
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
|
||||
| `/data/workspace/` | racine du dossier de travail de l'agent |
|
||||
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
|
||||
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
|
||||
@@ -175,11 +179,29 @@ 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).
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
|
||||
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
|
||||
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
|
||||
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
|
||||
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
|
||||
grossit sans rien apprendre de plus au modèle.
|
||||
- **Mémoire persistante** (`memory off|ondemand|always`).
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
|
||||
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
|
||||
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
|
||||
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
|
||||
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
|
||||
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
|
||||
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
|
||||
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
|
||||
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
|
||||
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
|
||||
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
|
||||
agent**, au même niveau de confiance que `bash`. La session navigateur est
|
||||
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
|
||||
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
|
||||
@@ -195,7 +217,48 @@ Héritées d'AJEAN :
|
||||
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
|
||||
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
|
||||
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt.
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
|
||||
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
|
||||
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
|
||||
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
|
||||
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
|
||||
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
|
||||
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
|
||||
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
|
||||
pilote que les tâches de ce projet.
|
||||
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
|
||||
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
|
||||
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
|
||||
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
|
||||
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
|
||||
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
|
||||
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
|
||||
le reste sous `/data` ; le corps de la notification reste générique (aucun
|
||||
extrait de réponse), puisqu'elle transite par Apple ou Google.
|
||||
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
|
||||
discussions, blocs archivés au compactage et trackers chiffrés en
|
||||
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
|
||||
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
|
||||
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
|
||||
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
|
||||
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
|
||||
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
|
||||
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
|
||||
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
|
||||
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
|
||||
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
|
||||
interrompue reprend au démarrage.
|
||||
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
|
||||
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
|
||||
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
|
||||
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
|
||||
reste chez toi.
|
||||
- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste
|
||||
française et « English » applique un dictionnaire sur la coque — navigation,
|
||||
intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en
|
||||
français** plutôt que d'afficher une clé technique, et le fil de discussion
|
||||
n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans
|
||||
toucher au reste de l'interface (`ui/src/js/28-i18n.js`).
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
|
||||
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
|
||||
@@ -228,7 +291,15 @@ Ajoutées par ce fork :
|
||||
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
|
||||
jamais bascule automatique. Conception reprise
|
||||
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
|
||||
`NOTICE.md`.
|
||||
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
|
||||
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
|
||||
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
|
||||
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
|
||||
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
|
||||
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
|
||||
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
|
||||
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
|
||||
appeler un autre.
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
@@ -255,6 +326,11 @@ Ajoutées par ce fork :
|
||||
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
|
||||
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
|
||||
raisonnement est coupé pour ce modèle.
|
||||
- **Ouverture rapide des longues discussions** : le serveur ne rejoue que la
|
||||
**fin** du journal (les 4000 derniers événements). Un fil de plusieurs
|
||||
centaines de tours s'affichait en quelques secondes et laissait autant de
|
||||
bulles dans le DOM à traîner ensuite. Rien n'est effacé : un bandeau en tête
|
||||
dit combien d'événements sont masqués et **charge le début** d'un clic.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
codeberg.org/readeck/go-readability/v2 v2.1.2
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/caddyserver/certmagic v0.25.4
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/getlantern/systray v1.2.2
|
||||
@@ -13,6 +14,7 @@ require (
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +30,7 @@ require (
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/itlightning/dateparse v0.2.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
@@ -45,7 +48,6 @@ require (
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
|
||||
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
|
||||
@@ -39,8 +41,10 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
@@ -102,24 +107,86 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -13,3 +13,4 @@ Rules:
|
||||
- You may NOT mark a criterion passed — the verification pass does that.
|
||||
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
||||
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
||||
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
|
||||
@@ -0,0 +1,220 @@
|
||||
package loki
|
||||
|
||||
// backup_bundle.go — construit et restaure un paquet de sauvegarde
|
||||
// {mémoire, presets, réglages}, CHIFFRÉ côté agent avant de quitter la machine.
|
||||
//
|
||||
// Le relais ne voit qu'un blob opaque (voir jean-relay/backup.go). Comme le tar
|
||||
// entier est chiffré, même les noms de fichiers et de presets sont cachés.
|
||||
//
|
||||
// Format du blob :
|
||||
// magic "LOKIBK1" || uint32(len entête) || entête JSON || corps
|
||||
// entête : { v, salt, kdf, box_pw, box_rec } — la DEK de sauvegarde wrappée
|
||||
// par la KEK(mot de passe) ET par la KEK(clé de récupération).
|
||||
// corps : gcmSeal(backupDEK, tar.gz du bundle).
|
||||
//
|
||||
// Restauration : mot de passe → KEK → déballe la DEK → déchiffre le corps. Ne
|
||||
// dépend d'AUCUN état de l'ancienne machine : un serveur vierge suffit.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var backupMagic = []byte("LKBK1") // v2 : entête = keyvault, corps chiffré par la DEK
|
||||
|
||||
const backupBodyAAD = "loki-backup-body"
|
||||
|
||||
// buildBundleTar empaquète mémoire + presets + réglages en un tar.gz.
|
||||
// Les fichiers mémoire sont pris TELS QUELS (chiffrés si le chiffrement est
|
||||
// actif) : le bundle reste donc chiffré même avant l'enveloppe de sauvegarde.
|
||||
func buildBundleTar() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
addDir := func(prefix, dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil // dossier absent = rien à ajouter
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &tar.Header{Name: prefix + "/" + e.Name(), Mode: 0o600, Size: int64(len(b))}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mémoire de TOUS les projets : un sous-dossier par projet (memory/<slug>/...).
|
||||
if roots, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := addDir("memory/"+d.Name(), filepath.Join(projectsRoot(), d.Name())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := addDir("presets", presetsDir()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Réglages (bucket config) sérialisés en JSON.
|
||||
cfg, _ := json.Marshal(ReadConfig())
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "config.json", Mode: 0o600, Size: int64(len(cfg))}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildBackupBlob assemble le paquet chiffré : magic || uint32(len keyvault) ||
|
||||
// keyvault JSON || gcmSeal(DEK, tar). AUCUN mot de passe de sauvegarde : le corps
|
||||
// est chiffré avec la DEK de la mémoire (déjà en RAM), et le keyvault (la DEK
|
||||
// wrappée par la clé d'API + la clé de récupération) voyage en entête. La
|
||||
// restauration n'a donc besoin que de la clé d'API. Le relais ne voit qu'un blob
|
||||
// opaque : le keyvault en entête ne contient que la DEK CHIFFRÉE.
|
||||
func buildBackupBlob(v *keyVault, dek, tarData []byte) ([]byte, error) {
|
||||
kv, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := gcmSeal(dek, tarData, []byte(backupBodyAAD))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.Write(backupMagic)
|
||||
var lenbuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenbuf[:], uint32(len(kv)))
|
||||
out.Write(lenbuf[:])
|
||||
out.Write(kv)
|
||||
out.Write(body)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// openBackupBlob lit l'entête keyvault, l'ouvre avec secret (clé d'API OU clé de
|
||||
// récupération) pour retrouver la DEK, puis déchiffre le corps.
|
||||
func openBackupBlob(blob []byte, secret string) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, backupMagic) {
|
||||
return nil, fmt.Errorf("format de sauvegarde non reconnu")
|
||||
}
|
||||
rest := blob[len(backupMagic):]
|
||||
if len(rest) < 4 {
|
||||
return nil, fmt.Errorf("blob tronqué")
|
||||
}
|
||||
n := binary.BigEndian.Uint32(rest[:4])
|
||||
rest = rest[4:]
|
||||
if int(n) > len(rest) {
|
||||
return nil, fmt.Errorf("entête tronqué")
|
||||
}
|
||||
var v keyVault
|
||||
if err := json.Unmarshal(rest[:n], &v); err != nil {
|
||||
return nil, fmt.Errorf("entête illisible : %w", err)
|
||||
}
|
||||
body := rest[n:]
|
||||
dek, _, err := v.unlockWith(secret)
|
||||
if err != nil {
|
||||
dek, _, err = v.unlockWith(normalizeRecovery(secret))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clé incorrecte")
|
||||
}
|
||||
return gcmOpen(dek, body, []byte(backupBodyAAD))
|
||||
}
|
||||
|
||||
// restoreBundleTar réécrit mémoire + presets + réglages depuis un tar.gz. Prend
|
||||
// D'ABORD un snapshot de sécurité de la mémoire, puis écrit de façon vérifiée.
|
||||
func restoreBundleTar(tarData []byte) error {
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(tarData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var cfgJSON []byte
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Les chemins tar sont TOUJOURS à séparateur « / » (jamais l'OS) : on
|
||||
// raisonne en slash, sinon le préfixe "memory/" casse sous Windows.
|
||||
name := path.Clean("/" + strings.ReplaceAll(hdr.Name, "\\", "/"))[1:]
|
||||
if strings.Contains(name, "..") {
|
||||
continue // sécurité : jamais de traversée
|
||||
}
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case name == "config.json":
|
||||
cfgJSON = b
|
||||
case strings.HasPrefix(name, "memory/"):
|
||||
// Chemin relatif SOUS memory/ (memory/<slug>/page.md). Les sauvegardes
|
||||
// d'avant les projets stockaient les pages à plat (memory/page.md) : on
|
||||
// les rattache au projet par défaut.
|
||||
rel := strings.TrimPrefix(name, "memory/")
|
||||
if !strings.Contains(rel, "/") {
|
||||
rel = defaultProjectSlug + "/" + rel
|
||||
}
|
||||
dst := filepath.Join(projectsRoot(), filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(dst, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasPrefix(name, "presets/"):
|
||||
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(presetsDir(), base), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cfgJSON) > 0 {
|
||||
var cfg map[string]string
|
||||
if json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
_ = WriteConfig(cfg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un paquet exporté doit se rouvrir avec la clé de pilotage comme avec la clé de
|
||||
// récupération, et rendre la mémoire à l'identique. C'est le scénario « je
|
||||
// remonte mon conteneur ailleurs » : rien d'autre que le fichier et la clé.
|
||||
func TestBackupBundleRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("notes.md", "# Notes\nsecret du client\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovery, err := EnableMemEncryption("clé-de-pilotage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
t.Fatalf("coffre introuvable après activation: %v", err)
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarData, err := buildBundleTar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := buildBackupBlob(v, dek, tarData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Le paquet ne doit rien laisser voir en clair.
|
||||
if strings.Contains(string(blob), "secret du client") {
|
||||
t.Fatal("le contenu d'une page apparaît en clair dans le paquet")
|
||||
}
|
||||
|
||||
// La clé de pilotage l'ouvre…
|
||||
if _, err := openBackupBlob(blob, "clé-de-pilotage"); err != nil {
|
||||
t.Fatalf("ouverture avec la clé de pilotage: %v", err)
|
||||
}
|
||||
// …la clé de récupération aussi…
|
||||
back, err := openBackupBlob(blob, recovery)
|
||||
if err != nil {
|
||||
t.Fatalf("ouverture avec la clé de récupération: %v", err)
|
||||
}
|
||||
// …et une mauvaise clé, non.
|
||||
if _, err := openBackupBlob(blob, "pas la bonne"); err == nil {
|
||||
t.Fatal("une clé fausse aurait dû être refusée")
|
||||
}
|
||||
|
||||
// Page effacée puis restaurée depuis le paquet : contenu intact.
|
||||
p, _ := safeMemPath("notes.md")
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restoreBundleTar(back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := memPageText("notes.md")
|
||||
if !ok || !strings.Contains(got, "secret du client") {
|
||||
t.Fatalf("page non restaurée: %q (lisible=%v)", got, ok)
|
||||
}
|
||||
// Le snapshot de sécurité pris avant restauration doit exister.
|
||||
if entries, err := os.ReadDir(filepath.Join(LokiHome(), "backups", "memory")); err != nil || len(entries) == 0 {
|
||||
t.Fatal("aucun snapshot pris avant la restauration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package loki
|
||||
|
||||
// browser_grid.go — surimpose une GRILLE DE COORDONNÉES sur le screenshot envoyé
|
||||
// au MODÈLE (pas sur celui sauvegardé pour l'utilisateur). Un petit modèle vise
|
||||
// mal au pixel près ; avec des lignes graduées tous les 100 px et des étiquettes
|
||||
// chiffrées, il lit x/y précisément et browser_click_xy tombe juste du premier
|
||||
// coup (bandeaux cookies en iframe, canvas, jeux…). Sans dépendance externe : la
|
||||
// police de chiffres est un bitmap 3×5 codé ici.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const gridStep = 100 // espacement des lignes, en pixels
|
||||
|
||||
// digits5x3 : bitmap 5 lignes × 3 colonnes pour chaque chiffre (# = pixel plein).
|
||||
var digits5x3 = map[rune][5]string{
|
||||
'0': {"###", "# #", "# #", "# #", "###"},
|
||||
'1': {" # ", "## ", " # ", " # ", "###"},
|
||||
'2': {"###", " #", "###", "# ", "###"},
|
||||
'3': {"###", " #", "###", " #", "###"},
|
||||
'4': {"# #", "# #", "###", " #", " #"},
|
||||
'5': {"###", "# ", "###", " #", "###"},
|
||||
'6': {"###", "# ", "###", "# #", "###"},
|
||||
'7': {"###", " #", " #", " #", " #"},
|
||||
'8': {"###", "# #", "###", "# #", "###"},
|
||||
'9': {"###", "# #", "###", " #", "###"},
|
||||
}
|
||||
|
||||
// overlayGrid décode un PNG, y dessine la grille + les graduations, et renvoie le
|
||||
// PNG résultant. En cas d'erreur (image illisible), renvoie l'entrée inchangée.
|
||||
func overlayGrid(raw []byte) []byte {
|
||||
src, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
b := src.Bounds()
|
||||
img := image.NewRGBA(b)
|
||||
draw.Draw(img, b, src, b.Min, draw.Src)
|
||||
|
||||
line := color.RGBA{255, 0, 200, 130} // magenta translucide pour les lignes
|
||||
labelBG := color.RGBA{0, 0, 0, 200} // fond sombre derrière les chiffres
|
||||
labelFG := color.RGBA{255, 255, 0, 255} // chiffres jaunes (contraste sur toute page)
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
// Lignes verticales + étiquette x en haut.
|
||||
for x := gridStep; x < w; x += gridStep {
|
||||
for y := 0; y < h; y++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+x+2, b.Min.Y+2, strconv.Itoa(x), labelFG, labelBG)
|
||||
}
|
||||
// Lignes horizontales + étiquette y à gauche.
|
||||
for y := gridStep; y < h; y += gridStep {
|
||||
for x := 0; x < w; x++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+2, b.Min.Y+y+2, strconv.Itoa(y), labelFG, labelBG)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return raw
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// blend applique une couleur semi-transparente (alpha) sur un pixel.
|
||||
func blend(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if !(image.Pt(x, y).In(img.Bounds())) {
|
||||
return
|
||||
}
|
||||
a := float64(c.A) / 255
|
||||
o := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(float64(c.R)*a + float64(o.R)*(1-a)),
|
||||
G: uint8(float64(c.G)*a + float64(o.G)*(1-a)),
|
||||
B: uint8(float64(c.B)*a + float64(o.B)*(1-a)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
|
||||
// drawLabel écrit un nombre (police 3×5, échelle ×2) avec un fond sombre pour
|
||||
// rester lisible sur n'importe quel fond.
|
||||
func drawLabel(img *image.RGBA, x, y int, s string, fg, bg color.RGBA) {
|
||||
const sc = 2
|
||||
glyphW, glyphH, gap := 3*sc, 5*sc, sc
|
||||
boxW := len(s)*(glyphW+gap) + gap
|
||||
boxH := glyphH + 2*gap
|
||||
// Fond.
|
||||
for dy := -gap; dy < boxH-gap; dy++ {
|
||||
for dx := -gap; dx < boxW-gap; dx++ {
|
||||
setOpaque(img, x+dx, y+dy, bg)
|
||||
}
|
||||
}
|
||||
// Chiffres.
|
||||
cx := x
|
||||
for _, r := range s {
|
||||
g, ok := digits5x3[r]
|
||||
if !ok {
|
||||
cx += glyphW + gap
|
||||
continue
|
||||
}
|
||||
for row := 0; row < 5; row++ {
|
||||
for col := 0; col < 3; col++ {
|
||||
if g[row][col] != '#' {
|
||||
continue
|
||||
}
|
||||
for iy := 0; iy < sc; iy++ {
|
||||
for ix := 0; ix < sc; ix++ {
|
||||
setOpaque(img, cx+col*sc+ix, y+row*sc+iy, fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += glyphW + gap
|
||||
}
|
||||
}
|
||||
|
||||
func setOpaque(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if image.Pt(x, y).In(img.Bounds()) {
|
||||
img.SetRGBA(x, y, color.RGBA{c.R, c.G, c.B, 255})
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,11 @@ func LoadConversation() {
|
||||
}
|
||||
// convEnsureActive reprend au passage le fil unique des versions
|
||||
// précédentes (clé « conversation ») comme première discussion.
|
||||
if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
|
||||
// getStoreBytesErr : même lecture, en DÉCHIFFRANT au besoin (mémoire
|
||||
// chiffrée). Verrouillé, elle rend (nil, nil) — pas une erreur d'accès :
|
||||
// on démarre alors sur un fil vide sans rien écraser, et tout revient au
|
||||
// déverrouillage.
|
||||
if b, err = getStoreBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
|
||||
break
|
||||
}
|
||||
retry()
|
||||
@@ -173,6 +177,19 @@ func (c *Conversation) loadFrom(b []byte) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// reloadEncryptedStores recharge la discussion depuis le disque après un
|
||||
// déverrouillage : verrouillée, la lecture au démarrage a rendu un fil vide.
|
||||
// On ne recharge QUE dans ce cas — écraser un fil déjà rempli ferait perdre le
|
||||
// tour en cours, qui n'a pas pu être persisté.
|
||||
func reloadEncryptedStores() {
|
||||
conv.mu.Lock()
|
||||
empty := len(conv.Log) == 0
|
||||
conv.mu.Unlock()
|
||||
if empty {
|
||||
LoadConversation()
|
||||
}
|
||||
}
|
||||
|
||||
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
|
||||
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
|
||||
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
|
||||
@@ -191,7 +208,10 @@ func (c *Conversation) persist() {
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
_ = putBytes(bkChat, convKey(id), b)
|
||||
// Chiffré si la mémoire l'est et qu'elle est déverrouillée. Verrouillée,
|
||||
// l'écriture est REFUSÉE plutôt que de remplacer un blob chiffré par du
|
||||
// clair — le fil de ce tour reste en RAM, rien n'est perdu sur disque.
|
||||
_ = putStoreBytes(bkChat, convKey(id), b)
|
||||
convTouchMeta(id, title, turns)
|
||||
}
|
||||
|
||||
@@ -497,6 +517,7 @@ func chatModelName() string {
|
||||
// tout ce que ce tour produirait ensuite (deltas, messages, persistance) est
|
||||
// abandonné au lieu de ressusciter des morceaux de l'ancienne conversation.
|
||||
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
turnStart := time.Now()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
stale := c.epoch != epoch
|
||||
@@ -518,6 +539,17 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
// Notification Web Push : ce chemin (generate) ne sert QUE les tours
|
||||
// utilisateur — les tâches de fond passent par RunAutonomous et ont leur
|
||||
// propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers
|
||||
// le service de push ne doit pas retenir la fin du tour. Corps générique
|
||||
// (pas d'extrait de réponse) : la notif transite par Apple/Google.
|
||||
//
|
||||
// ctx.Err() != nil = tour interrompu par un « stop » : pas de notification,
|
||||
// l'utilisateur est là et a coupé volontairement.
|
||||
if hasPushSubs() && ctx.Err() == nil {
|
||||
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
|
||||
}
|
||||
}()
|
||||
|
||||
// Snapshot de la vue modèle.
|
||||
@@ -567,6 +599,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
}
|
||||
// Taille réelle du résultat (l'aperçu n'en montre qu'un début) : le
|
||||
// compteur ~N tok de la bulle affiche la vraie valeur, pas celle de
|
||||
// l'aperçu.
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
// Référence pour charger le reste à la demande (bouton « voir plus »).
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
// Corps en cours de frappe : transitoire (l'état final est le diff), on
|
||||
// ne l'ajoute que quand il est là pour ne pas gonfler chaque événement.
|
||||
if ev.ToolUsed.Body != "" {
|
||||
@@ -910,7 +952,18 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
// de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement.
|
||||
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
|
||||
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
|
||||
// replayTailMax borne le nombre d'événements REJOUÉS au chargement d'une
|
||||
// discussion. Ce n'est pas une troncature du journal (rien n'est effacé) : le
|
||||
// début reste sur disque et le client le demande d'un clic (full=true).
|
||||
//
|
||||
// Une discussion de plusieurs centaines de tours rejouait des dizaines de
|
||||
// milliers d'événements à chaque ouverture d'onglet : quelques secondes de voile
|
||||
// de chargement, et autant de bulles dans le DOM que le navigateur devait ensuite
|
||||
// traîner à chaque rendu. On rejoue la fin — ce qu'on vient lire — et on annonce
|
||||
// combien d'événements sont restés en arrière.
|
||||
const replayTailMax = 4000
|
||||
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit func(map[string]any) bool) {
|
||||
// Réveille les attentes de cond quand la connexion se ferme.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
@@ -946,7 +999,19 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
from = 0
|
||||
}
|
||||
last := from
|
||||
for _, ev := range coalesceReplay(snapshot, from) {
|
||||
events := coalesceReplay(snapshot, from)
|
||||
// Replay borné : on ne garde que la fin, et on DIT au client combien
|
||||
// d'événements manquent devant (il affiche « charger le début »). Jamais
|
||||
// borné sur une reprise de flux (from > 0) : là, le client a déjà tout le
|
||||
// début à l'écran et attend la SUITE.
|
||||
if !full && from == 0 && len(events) > replayTailMax {
|
||||
hidden := len(events) - replayTailMax
|
||||
events = events[hidden:]
|
||||
if !emit(map[string]any{"replay_truncated": hidden}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestSubscribeReplayAndLive(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 32)
|
||||
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestSubscribeFromOffset(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, 1, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 1, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestResetNotifiesSubscribers(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
resetSeen := make(chan bool, 4)
|
||||
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, false, func(m map[string]any) bool {
|
||||
if _, ok := m["reset"]; ok {
|
||||
resetSeen <- true
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
} {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, tc.from, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, tc.from, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -216,7 +216,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, 502, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 502, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func TestSubscribeStaleFromCrossSession(t *testing.T) {
|
||||
var got []map[string]any
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
|
||||
c.Subscribe(ctx, 9999, false, func(ev map[string]any) bool {
|
||||
got = append(got, ev)
|
||||
if ev["caught_up"] != nil {
|
||||
cancel()
|
||||
@@ -309,3 +309,50 @@ func TestSubscribeStaleFromCrossSession(t *testing.T) {
|
||||
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay borné : une discussion très longue ne rejoue que sa fin, annonce
|
||||
// combien d'événements manquent, et rend TOUT si le client le demande.
|
||||
func TestReplayTailIsBoundedUnlessFull(t *testing.T) {
|
||||
c := &Conversation{}
|
||||
c.cond = sync.NewCond(&c.mu)
|
||||
for i := 0; i < replayTailMax+50; i++ {
|
||||
c.Log = append(c.Log, LogEvent{Seq: i + 1, Delta: map[string]any{"user": "m"}})
|
||||
}
|
||||
|
||||
collect := func(full bool) (events int, hidden int) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
c.Subscribe(ctx, 0, full, func(m map[string]any) bool {
|
||||
if h, ok := m["replay_truncated"].(int); ok {
|
||||
hidden = h
|
||||
return true
|
||||
}
|
||||
if m["caught_up"] != nil {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
if _, ok := m["pad"]; ok {
|
||||
return true
|
||||
}
|
||||
events++
|
||||
return true
|
||||
})
|
||||
}()
|
||||
<-done
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
events, hidden := collect(false)
|
||||
if hidden != 50 {
|
||||
t.Fatalf("50 événements auraient dû être annoncés masqués, obtenu %d", hidden)
|
||||
}
|
||||
if events > replayTailMax {
|
||||
t.Fatalf("replay borné dépassé : %d événements rejoués", events)
|
||||
}
|
||||
if full, h := collect(true); full <= replayTailMax || h != 0 {
|
||||
t.Fatalf("full=true doit tout rejouer sans bandeau, obtenu %d événements / %d masqués", full, h)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func migrateLegacyUploads(src string) {
|
||||
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
|
||||
// relie un fichier déposé à sa discussion.
|
||||
func convAttachNames(id string) []string {
|
||||
b := getBytes(bkChat, convKey(id))
|
||||
b := storeBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -97,7 +97,8 @@ func webGrepTool() Tool {
|
||||
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
|
||||
|
||||
// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
|
||||
// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce
|
||||
// toolMaxOutput (8000) pour le shell. (La mémoire et MCP, eux, renvoient tout
|
||||
// au modèle et laissent l'UI charger l'aperçu.) Sans ce
|
||||
// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
|
||||
// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
|
||||
// déclenchait des compactages en cascade au milieu du raisonnement.
|
||||
|
||||
@@ -89,8 +89,15 @@ func MemList() []MemPage {
|
||||
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
continue
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
|
||||
out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
|
||||
// Page chiffrée + mémoire verrouillée : on la LISTE quand même (elle
|
||||
// existe, la cacher ferait croire à une perte) avec un titre qui dit
|
||||
// pourquoi son contenu n'apparaît pas.
|
||||
txt, ok := memPageText(e.Name())
|
||||
title := titleOf(txt)
|
||||
if !ok {
|
||||
title = "🔒 (chiffré — mémoire verrouillée)"
|
||||
}
|
||||
out = append(out, MemPage{Name: e.Name(), Title: title})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
@@ -125,8 +132,7 @@ func MemSearch(query string, limit int) []MemHit {
|
||||
docs := make([]doc, 0)
|
||||
df := make(map[string]int) // nb de pages contenant chaque terme
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul
|
||||
d := doc{
|
||||
p: p,
|
||||
name: strings.ToLower(p.Name),
|
||||
@@ -246,12 +252,11 @@ func snippetAround(content string, terms []string) string {
|
||||
// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du
|
||||
// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500).
|
||||
func MemRead(name string, offset, limit int) (string, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return "", fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return "", fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
@@ -278,11 +283,8 @@ func MemAdd(name, content string) error {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier")
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
|
||||
@@ -298,12 +300,11 @@ func MemAdd(name, content string) error {
|
||||
var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification")
|
||||
|
||||
func MemEdit(name, oldText, newText string) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
content := string(b)
|
||||
@@ -323,17 +324,13 @@ func MemEdit(name, oldText, newText string) error {
|
||||
return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
return os.WriteFile(p, []byte(updated), 0o644)
|
||||
return writeMemFile(name, []byte(updated))
|
||||
}
|
||||
|
||||
// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
|
||||
// l'éditeur web.
|
||||
func MemContent(name string) string {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -353,15 +350,8 @@ func MemSave(name, old, content string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
|
||||
|
||||
@@ -26,10 +26,27 @@ import (
|
||||
// et recherches déjà faites. Il récupère bien plus qu'il ne coûte.
|
||||
// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre
|
||||
// actions — quatre outils auraient coûté quatre fois ça.
|
||||
// 8600 → 11000 : les quatre outils task_* (tasks_tools.go), ~1290 car. L'IA se
|
||||
// donne elle-même ses rappels et ses veilles ; sans eux il fallait
|
||||
// passer par l'interface. Descriptions déjà au plus court, et un
|
||||
// seul schéma par action (create/update/delete ne partagent pas
|
||||
// assez d'arguments pour fusionner sans rendre chacun ambigu).
|
||||
// Les ~500 car restants couvrent web_screenshot, déclaré dès que
|
||||
// Playwright est présent — c'est le cas dans l'image, pas sur le
|
||||
// runner de CI : le budget doit tenir pour l'image, la vraie.
|
||||
// (Le pilotage de navigateur, lui, est HORS de ce décompte : ses
|
||||
// schémas n'existent que si on arme son interrupteur.)
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire dans les deux cas. La consigne
|
||||
// d'origine tient pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 8600 // ~2150 tokens, tout allumé
|
||||
// Les ~200 car de prompt qui les accompagnent sont le seul écart
|
||||
// assumé à la règle « pas de relèvement pour du prompt » : un outil
|
||||
// qu'aucune ligne ne mentionne n'est jamais appelé de lui-même, et
|
||||
// la ligne dit ce que les schémas ne disent pas (quand se planifier,
|
||||
// et qu'un script planifié ne charge aucun modèle). Elle a été
|
||||
// ramenée de trois lignes à une.
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire. La consigne d'origine tient
|
||||
// pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 11000 // ~2750 tokens, tout allumé
|
||||
|
||||
func TestSystemPromptStaysLean(t *testing.T) {
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
|
||||
@@ -84,8 +84,14 @@ func recallIDToSeq(id string) (uint64, bool) {
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var id string
|
||||
err := withDB(func(d *bolt.DB) error {
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
@@ -108,7 +114,15 @@ func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), raw)
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
@@ -131,7 +145,11 @@ func recallGet(id string) (recallBlock, bool) {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
found = json.Unmarshal(v, &blk) == nil
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : le bloc reste hors de portée
|
||||
}
|
||||
found = json.Unmarshal(dec, &blk) == nil
|
||||
return nil
|
||||
})
|
||||
return blk, found
|
||||
@@ -161,7 +179,11 @@ func recallSearch(query string, limit int) []recallBlock {
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var blk recallBlock
|
||||
if json.Unmarshal(v, &blk) != nil {
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : ce bloc ne participe pas
|
||||
}
|
||||
if json.Unmarshal(dec, &blk) != nil {
|
||||
return nil
|
||||
}
|
||||
label := strings.ToLower(blk.Label)
|
||||
|
||||
@@ -16,7 +16,6 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -213,11 +212,13 @@ func screenshotImageMessage(relPath string) (Message, bool) {
|
||||
if err != nil || len(b) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
// Redimensionnement sous maxImageDim avant l'envoi : une capture de page
|
||||
// fait facilement 1500×4000 px, dont le base64 pèse plus que ce que le
|
||||
// projecteur regarde (voir prepareImageForModel, web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
}},
|
||||
imageURLPart(b, mime),
|
||||
}}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package loki
|
||||
|
||||
// chat_scripts.go — le dossier de scripts DÉDIÉ et PROTÉGÉ de l'agent.
|
||||
//
|
||||
// La durabilité des scripts vient d'une séparation STRUCTURELLE, pas d'un verrou
|
||||
// sur les commandes : les scripts qu'on veut CONSERVER vivent dans scriptsDir() =
|
||||
// LOKI_HOME/scripts, à côté de memory, HORS du workspace jetable. Un nettoyage —
|
||||
// ou une catastrophe — dans le workspace ne peut plus les toucher. Le workspace
|
||||
// reste le bac à sable où l'IA peut tout casser sans risque (clones, tests,
|
||||
// fichiers temporaires).
|
||||
//
|
||||
// L'IA a par ailleurs la main libre sur le shell (suppression/déplacement/
|
||||
// renommage inclus) : aucun garde-fou « anti-rm » ne filtre les commandes. Le
|
||||
// SEUL accès réservé est le dossier memory, joignable uniquement par les outils
|
||||
// mem_* (voir guardToolOnly plus bas) — parce qu'il est chiffré, pas pour le
|
||||
// protéger d'une suppression.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scriptsPath résout un nom de script fourni par le modèle vers un chemin DANS
|
||||
// scriptsDir, en refusant toute évasion hors du dossier (« ../ », chemin absolu).
|
||||
// Renvoie une erreur plutôt qu'un chemin hors périmètre.
|
||||
func scriptsPath(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("nom de script vide")
|
||||
}
|
||||
// On interdit les chemins absolus et la remontée : un script vit dans
|
||||
// scriptsDir, point. Les sous-dossiers relatifs restent permis. Un nom qui
|
||||
// commence par un séparateur (« /etc/… ») est rejeté explicitement : sous
|
||||
// Windows il n'est pas « absolu » au sens de filepath mais n'a rien à faire là.
|
||||
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
|
||||
return "", fmt.Errorf("nom de script invalide (commence par un séparateur) : %s", name)
|
||||
}
|
||||
// Caractères hostiles au shell : le nom finit dans une ligne de commande
|
||||
// (scriptRunCommand l'entoure de guillemets) ; on refuse tout ce qui pourrait
|
||||
// en sortir. Le backslash est inclus : sous Windows c'est un séparateur (utiliser
|
||||
// « / » pour les sous-dossiers), sous Unix c'est un échappement shell. Refuser
|
||||
// partout rend le comportement identique sur les deux plateformes. Un nom de
|
||||
// fichier n'a de toute façon besoin d'aucun de ces caractères.
|
||||
if i := strings.IndexAny(name, "\"'`$;&|<>*?\\\n\r"); i >= 0 {
|
||||
return "", fmt.Errorf("nom de script invalide (caractère interdit %q) : %s", name[i:i+1], name)
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(name))
|
||||
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("nom de script invalide (pas de chemin absolu ni de ../) : %s", name)
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), clean)
|
||||
// Ceinture et bretelles : vérifie que le résultat est bien SOUS scriptsDir.
|
||||
if !underDir(full, scriptsDir()) {
|
||||
return "", fmt.Errorf("nom de script hors du dossier scripts : %s", name)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// scriptExists valide qu'un nom désigne bien un fichier script existant dans le
|
||||
// dossier (sans lire son contenu). Utilisé pour valider une tâche « script seul ».
|
||||
func scriptExists(name string) error {
|
||||
full, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return fmt.Errorf("script introuvable : %s", name)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("%s est un dossier, pas un script", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listScripts renvoie les scripts présents (chemins relatifs à scriptsDir, avec
|
||||
// leur taille), triés. Ignore les dossiers.
|
||||
func listScripts() ([]scriptInfo, error) {
|
||||
root := scriptsDir()
|
||||
var out []scriptInfo
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // dossier absent ou illisible : on renvoie ce qu'on a
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
size := int64(0)
|
||||
if fi, ferr := d.Info(); ferr == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
out = append(out, scriptInfo{Name: filepath.ToSlash(rel), Size: size})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, err
|
||||
}
|
||||
|
||||
type scriptInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// toolOnlyDirs : dossiers auxquels l'IA n'a AUCUN accès direct (ni lecture, ni
|
||||
// écriture, ni listing) par bash/write/edit. Le seul chemin autorisé passe par
|
||||
// les outils dédiés — mem_* pour la mémoire. Ces outils écrivent en Go
|
||||
// directement (MemAdd…), ils ne passent PAS par fileWrite/runShell, donc ils ne
|
||||
// sont pas concernés par ces gardes.
|
||||
//
|
||||
// Le dossier scripts N'EST PAS ici : l'IA y écrit et y exécute ses scripts
|
||||
// librement (avec write/bash). Sa durabilité vient seulement de sa séparation
|
||||
// d'avec le workspace jetable, pas d'un verrou.
|
||||
func toolOnlyDirs() []string { return []string{memoryDir()} }
|
||||
|
||||
// toolOnlyLabel donne le nom des outils à utiliser à la place d'un accès direct
|
||||
// (seule la mémoire est concernée aujourd'hui, voir toolOnlyDirs).
|
||||
func toolOnlyLabel() string {
|
||||
return "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)"
|
||||
}
|
||||
|
||||
// guardToolOnlyPath refuse un accès write/edit à un chemin (déjà résolu) situé
|
||||
// dans un dossier « tool-only » (memory). Renvoie "" si le chemin est autorisé.
|
||||
func guardToolOnlyPath(path string) string {
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if underDir(path, d) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// guardToolOnlyCommand refuse une commande shell qui référence un dossier
|
||||
// « tool-only » (memory), lecture comprise (`cat`, `ls`, `>`…) : on bloque TOUTE
|
||||
// mention du dossier, pas seulement une suppression. Renvoie "" si autorisé.
|
||||
func guardToolOnlyCommand(command string) string {
|
||||
lc := strings.ToLower(command)
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if strings.Contains(lc, strings.ToLower(normPath(d))) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s via le shell. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// underDir indique si path est égal à dir ou situé dessous, après nettoyage et
|
||||
// (sous Windows) insensibilité à la casse.
|
||||
func underDir(path, dir string) bool {
|
||||
a := normPath(path)
|
||||
b := normPath(dir)
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(a, b+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// normPath normalise un chemin pour la comparaison : absolu si possible, nettoyé,
|
||||
// et minusculé sous Windows (système de fichiers insensible à la casse).
|
||||
func normPath(p string) string {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
if runtime.GOOS == "windows" {
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGuardToolOnly vérifie que memory est inaccessible en direct (shell/write/
|
||||
// edit) et n'autorise que les outils dédiés, tandis que scripts reste libre.
|
||||
func TestGuardToolOnly(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
// Shell : toute mention du dossier memory est refusée (lecture comprise). Le
|
||||
// dossier scripts, lui, est librement utilisable (l'IA y écrit et exécute).
|
||||
blockedCmds := []string{
|
||||
"cat " + memoryDir() + "/note.md",
|
||||
"echo x > " + memoryDir() + "/x",
|
||||
}
|
||||
for _, c := range blockedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg == "" {
|
||||
t.Errorf("commande aurait dû être refusée : %q", c)
|
||||
}
|
||||
}
|
||||
allowedCmds := []string{
|
||||
"ls " + workspaceDir(),
|
||||
"bash " + scriptsDir() + "/backup.sh", // exécuter un script : autorisé
|
||||
"ls " + scriptsDir(),
|
||||
}
|
||||
for _, c := range allowedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg != "" {
|
||||
t.Errorf("commande ne devait pas être bloquée : %q -> %s", c, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// write/edit : un chemin dans memory est refusé ; dans scripts ou workspace, non.
|
||||
if msg := guardToolOnlyPath(filepath.Join(memoryDir(), "a.md")); msg == "" {
|
||||
t.Errorf("écriture directe dans memory aurait dû être refusée")
|
||||
}
|
||||
for _, p := range []string{filepath.Join(scriptsDir(), "b.sh"), filepath.Join(workspaceDir(), "ok.txt")} {
|
||||
if msg := guardToolOnlyPath(p); msg != "" {
|
||||
t.Errorf("écriture ne devait pas être bloquée : %q -> %s", p, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptsPathEscape vérifie qu'on ne peut pas sortir du dossier scripts ni
|
||||
// glisser un caractère hostile au shell dans le nom.
|
||||
func TestScriptsPathEscape(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
bad := []string{"../evil.sh", "..", "/etc/passwd", `..\..\x`, `a";rm -rf ~;".sh`, "a$b.sh", "a`b`.sh"}
|
||||
for _, name := range bad {
|
||||
if _, err := scriptsPath(name); err == nil {
|
||||
t.Errorf("aurait dû refuser le nom : %q", name)
|
||||
}
|
||||
}
|
||||
good := []string{"backup.ps1", "sub/backup.sh", "a.sh", "my backup.sh"}
|
||||
for _, name := range good {
|
||||
p, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
t.Errorf("aurait dû accepter %q : %v", name, err)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(normPath(p), normPath(scriptsDir())) {
|
||||
t.Errorf("%q résolu hors de scriptsDir : %s", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptExists couvre la validation d'existence utilisée par les tâches script.
|
||||
func TestScriptExists(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
if err := scriptExists("hello.sh"); err == nil {
|
||||
t.Fatalf("un script absent aurait dû échouer")
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), "hello.sh")
|
||||
if err := os.MkdirAll(scriptsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("hello.sh"); err != nil {
|
||||
t.Fatalf("scriptExists : %v", err)
|
||||
}
|
||||
list, _ := listScripts()
|
||||
if len(list) != 1 || list[0].Name != "hello.sh" {
|
||||
t.Fatalf("list inattendu : %+v", list)
|
||||
}
|
||||
// Un dossier n'est pas un script.
|
||||
if err := os.MkdirAll(filepath.Join(scriptsDir(), "d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("d"); err == nil {
|
||||
t.Fatalf("un dossier ne doit pas passer pour un script")
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,10 @@ func tagOrphanConversations(slug string) {
|
||||
|
||||
func convIndex() []convMeta {
|
||||
var idx []convMeta
|
||||
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
|
||||
// L'index porte les TITRES des discussions : chiffré comme leur contenu
|
||||
// quand la mémoire l'est. Verrouillé, il se lit vide — la liste est masquée,
|
||||
// rien n'est perdu.
|
||||
if b, ok := getStoreBytes(bkChat, ckIndex); ok && len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &idx)
|
||||
}
|
||||
// Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de
|
||||
@@ -115,7 +118,7 @@ func convIndexSave(idx []convMeta) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, ckIndex, b)
|
||||
_ = putStoreBytes(bkChat, ckIndex, b)
|
||||
}
|
||||
|
||||
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
|
||||
@@ -128,7 +131,7 @@ func convEnsureActive() string {
|
||||
if legacy := getBytes(bkChat, ckLegacy); len(legacy) > 0 {
|
||||
// Migration : le fil unique devient la première discussion. On garde la
|
||||
// clé d'origine intacte — en cas de retour arrière, rien n'est perdu.
|
||||
_ = putBytes(bkChat, convKey(id), legacy)
|
||||
_ = putStoreBytes(bkChat, convKey(id), legacy)
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
now := time.Now().Unix()
|
||||
@@ -215,7 +218,7 @@ func projectSwitch(slug string) error {
|
||||
}
|
||||
// convIndex rend la plus récemment modifiée en tête.
|
||||
_ = putStr(bkChat, ckActive, list[0].ID)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(list[0].ID)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(list[0].ID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -243,7 +246,7 @@ func convSwitch(id string) error {
|
||||
}
|
||||
conv.persist() // fige la discussion qu'on quitte
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(id)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(id)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -323,6 +326,6 @@ func convDelete(id string) error {
|
||||
return nil
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, next[0].ID)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(next[0].ID)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(next[0].ID)))
|
||||
return nil
|
||||
}
|
||||
@@ -89,6 +89,13 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
|
||||
// à connaître pour le modèle — juste [texte](chemin).
|
||||
b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n")
|
||||
// Auto-planification : les schémas task_* partent déjà dans la requête, donc
|
||||
// on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en
|
||||
// servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit)
|
||||
// et le fait qu'un script planifié tourne sans modèle, que les schémas ne
|
||||
// disent pas. Une ligne, pas trois : le budget du préambule est compté
|
||||
// (TestSystemPromptStaysLean).
|
||||
b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n")
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
|
||||
}
|
||||
@@ -101,6 +108,9 @@ func baseSystemPrompt(caps Caps) string {
|
||||
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
|
||||
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
|
||||
}
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
b.WriteString(cuPromptLine())
|
||||
}
|
||||
if caps.Agent {
|
||||
if line := mcpPromptLine(); line != "" {
|
||||
b.WriteString(line)
|
||||
@@ -177,7 +187,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
|
||||
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
|
||||
// aux « installations » en /usr/local/bin qui échouent faute de root.
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder when the user explicitly named that location.")
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -192,6 +202,14 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du
|
||||
// délai (5 minutes au maximum), bouton stop sans effet.
|
||||
func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell
|
||||
// (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent
|
||||
// tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et
|
||||
// laissait le modèle croire qu'il avait lu une page que la mémoire, elle,
|
||||
// n'avait pas servie.
|
||||
if msg := guardToolOnlyCommand(command); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = toolDefaultTimeout
|
||||
}
|
||||
@@ -274,6 +292,10 @@ func fileWrite(path, content string) string {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
@@ -312,6 +334,10 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
|
||||
@@ -80,7 +80,17 @@ func toolSeeImage(path string) (string, map[string]any) {
|
||||
if len(b) == 0 {
|
||||
return "[erreur] fichier vide : " + path, nil
|
||||
}
|
||||
return "[ok] image chargée : " + filepath.Base(abs), map[string]any{
|
||||
// Même préparation que les pièces jointes : orientation EXIF redressée et
|
||||
// grand côté ramené sous maxImageDim (voir web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return "[ok] image chargée : " + filepath.Base(abs), imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// imageURLPart construit la partie multimodale {type:image_url, image_url:{url}}
|
||||
// à partir d'octets image déjà préparés — le SEUL format qu'un llama-server
|
||||
// --mmproj comprend. Partagé par see_image, web_screenshot et browser_screenshot.
|
||||
func imageURLPart(b []byte, mime string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
@@ -93,8 +103,14 @@ func toolSeeImage(path string) (string, map[string]any) {
|
||||
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
|
||||
// dirait pas LAQUELLE rouvrir.
|
||||
func seeImageMessage(label string, img map[string]any) Message {
|
||||
// Sans parenthèses vides : browser_screenshot n'a pas d'argument (label=""),
|
||||
// là où see_image porte le chemin du fichier.
|
||||
text := "Image :"
|
||||
if label != "" {
|
||||
text = "Image demandée (" + label + ") :"
|
||||
}
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Image demandée (" + label + ") :"},
|
||||
{"type": "text", "text": text},
|
||||
img,
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package loki
|
||||
|
||||
// code_subagent.go — l'outil `subagent` du mode code : déléguer une question
|
||||
// bornée à un RÔLE qui travaille dans SON PROPRE contexte, et n'en ramener que
|
||||
// la réponse.
|
||||
//
|
||||
// Repris de l'idée des sous-agents d'OpenFox (voir NOTICE.md), réécrit pour le
|
||||
// fil unique de Loki, sur la mécanique déjà en place pour la passe de
|
||||
// vérification (code_verify.go : un runChat isolé, non persisté).
|
||||
//
|
||||
// Pourquoi ça compte sur un modèle LOCAL : la fenêtre de contexte est petite.
|
||||
// « Trouve où est géré le cache » coûte dix lectures de fichiers qui restent
|
||||
// ensuite dans l'historique jusqu'à la compaction, alors que seule la RÉPONSE
|
||||
// comptait. Le sous-agent paie ces lectures dans un contexte jetable et ne rend
|
||||
// que le résultat : le fil du builder ne grossit que d'un résultat d'outil.
|
||||
//
|
||||
// Trois rôles, tous en LECTURE SEULE (voir EnabledTools) : explorer (cartographie
|
||||
// le code), code-reviewer (relit un changement), planner (découpe un travail).
|
||||
// Aucun n'écrit : ce qui modifie le dépôt reste dans le fil principal, sous les
|
||||
// yeux de l'utilisateur.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// subagentRoles : les rôles délégables. `verifier` n'en fait PAS partie — cette
|
||||
// passe est pilotée par la boucle du contrat (code_verify.go), c'est la seule
|
||||
// habilitée à marquer un critère passé, et la laisser s'appeler à la demande
|
||||
// permettrait au builder de se décerner son propre satisfecit.
|
||||
var subagentRoles = []string{"explorer", "code-reviewer", "planner"}
|
||||
|
||||
func isSubagentRole(r string) bool {
|
||||
for _, x := range subagentRoles {
|
||||
if x == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// subagentMaxOutput borne ce que le sous-agent ramène dans le contexte de
|
||||
// l'appelant. Au-delà, l'économie de contexte serait perdue — c'est un rapport,
|
||||
// pas un vidage de fichiers.
|
||||
const subagentMaxOutput = 6000
|
||||
|
||||
func subagentTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "subagent",
|
||||
// Description au plus court : elle part dans CHAQUE requête (budget du
|
||||
// préambule, cf. TestSystemPromptStaysLean).
|
||||
Description: "Délègue une recherche ou une relecture à un rôle qui travaille dans son propre contexte et ne rend que sa réponse. Lecture seule.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"role": map[string]any{
|
||||
"type": "string",
|
||||
"enum": subagentRoles,
|
||||
"description": "explorer (trouver/cartographier), code-reviewer (relire un changement), planner (découper)",
|
||||
},
|
||||
"task": map[string]any{"type": "string", "description": "La question, précise et autonome : le rôle ne voit pas la discussion."},
|
||||
},
|
||||
"required": []string{"role", "task"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// toolSubagent exécute la délégation. Le contexte du sous-agent est ISOLÉ : le
|
||||
// prompt de son rôle, le briefing machine, et la tâche — rien de l'historique
|
||||
// du fil, que précisément on ne veut pas payer deux fois. Sa trace n'est ni
|
||||
// persistée ni diffusée : seul son texte final revient.
|
||||
func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string {
|
||||
role, _ := args["role"].(string)
|
||||
task, _ := args["task"].(string)
|
||||
role = strings.TrimSpace(role)
|
||||
task = strings.TrimSpace(task)
|
||||
if !isSubagentRole(role) {
|
||||
return "[erreur] rôle inconnu : " + role + " (attendu : " + strings.Join(subagentRoles, ", ") + ")"
|
||||
}
|
||||
if task == "" {
|
||||
return "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire"
|
||||
}
|
||||
// Garde-fou anti-récursion : un sous-agent ne délègue pas à son tour. Sans
|
||||
// ça, un modèle qui boucle ouvre autant de contextes que de tours, et la
|
||||
// facture en jetons devient exponentielle pour une question unique.
|
||||
if parent.Role != "" && parent.Role != "builder" {
|
||||
return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
|
||||
}
|
||||
|
||||
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
|
||||
// Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de
|
||||
// rôle parle à un humain (« termine en demandant confirmation ») ; ici son
|
||||
// interlocuteur est le builder, et sa réponse est un résultat d'outil.
|
||||
framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
if role == "planner" {
|
||||
framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
}
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
|
||||
{Role: "user", Content: task + framing},
|
||||
}
|
||||
var out strings.Builder
|
||||
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
|
||||
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
|
||||
if _, err := runChat(ctx, msgs, 0, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
out.WriteString(ev.Content)
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
return "[erreur] sous-agent " + role + " : " + err.Error()
|
||||
}
|
||||
res := strings.TrimSpace(out.String())
|
||||
if res == "" {
|
||||
return "[erreur] le sous-agent " + role + " n'a rien répondu"
|
||||
}
|
||||
if r := []rune(res); len(r) > subagentMaxOutput {
|
||||
res = string(r[:subagentMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return "[" + role + "]\n" + res
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les garde-fous de l'outil subagent doivent répondre SANS appeler le modèle :
|
||||
// un rôle inconnu, une tâche vide ou un appel depuis un sous-agent sont refusés
|
||||
// à l'entrée (sinon chaque faute de frappe coûte une génération complète).
|
||||
func TestSubagentRefusesBeforeAnyInference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
parent Caps
|
||||
want string
|
||||
}{
|
||||
{"rôle inconnu", map[string]any{"role": "architecte", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"rôle verifier refusé", map[string]any{"role": "verifier", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"tâche vide", map[string]any{"role": "explorer", "task": " "}, Caps{Code: true}, "tâche vide"},
|
||||
{"pas de récursion", map[string]any{"role": "explorer", "task": "x"}, Caps{Code: true, Role: "explorer"}, "ne peut pas en appeler un autre"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := toolSubagent(context.Background(), tc.args, tc.parent)
|
||||
if !strings.HasPrefix(got, "[erreur]") || !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("attendu un refus contenant %q, obtenu %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Un rôle délégué travaille en LECTURE SEULE : ni write/edit (les modifications
|
||||
// restent dans le fil principal), ni subagent (pas de récursion), et seul le
|
||||
// planner pose des critères.
|
||||
func TestSubagentRolesAreReadOnly(t *testing.T) {
|
||||
forbidden := map[string]bool{"write": true, "edit": true, "subagent": true, "mem_add": true, "mem_edit": true, "web_search": true}
|
||||
for _, role := range subagentRoles {
|
||||
names := map[string]bool{}
|
||||
for _, tool := range EnabledTools(Caps{Agent: true, Code: true, Role: role}) {
|
||||
names[tool.Function.Name] = true
|
||||
}
|
||||
for f := range forbidden {
|
||||
if names[f] {
|
||||
t.Fatalf("le rôle %s ne devrait pas avoir l'outil %s", role, f)
|
||||
}
|
||||
}
|
||||
if !names["read"] || !names["grep"] {
|
||||
t.Fatalf("le rôle %s devrait pouvoir lire le code", role)
|
||||
}
|
||||
if got := names["criteria"]; got != (role == "planner") {
|
||||
t.Fatalf("critères pour %s : %v (attendu %v)", role, got, role == "planner")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
package loki
|
||||
|
||||
// computer_cdp.go — pilote un Chromium/Chrome LOCAL via le protocole DevTools
|
||||
// (CDP), en Go pur (websocket coder/websocket déjà présent), sans dépendance
|
||||
// lourde type chromedp/playwright. C'est le moteur « web » du computer use :
|
||||
// ouvrir une URL, lire l'arbre d'accessibilité (éléments interactifs numérotés,
|
||||
// façon set-of-marks), cliquer/taper/défiler, et — à la demande — capturer un
|
||||
// screenshot pour la vision.
|
||||
//
|
||||
// Choix d'archi : le conteneur Loki embarque déjà un Chromium (celui de
|
||||
// Playwright, cf. Dockerfile) ; on le pilote en CDP. Une seule session navigateur
|
||||
// est maintenue (lazy) et réutilisée entre les appels d'outils, comme le cache
|
||||
// web de chat_internet.go.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
cdpMu sync.Mutex
|
||||
cdpCur *cdpSession
|
||||
)
|
||||
|
||||
// Taille FIXE du viewport piloté (et donc du screenshot). Communiquée au modèle
|
||||
// pour que ses coordonnées browser_click_xy lues sur l'image tombent juste.
|
||||
const (
|
||||
cuViewW = 1280
|
||||
cuViewH = 800
|
||||
)
|
||||
|
||||
// cdpSession détient le process Chrome, la connexion websocket CDP vers la cible
|
||||
// « page », et le multiplexage requête/réponse (un lecteur en tâche de fond
|
||||
// distribue les réponses aux appels en attente par id).
|
||||
type cdpSession struct {
|
||||
cmd *exec.Cmd
|
||||
userDir string
|
||||
ws *websocket.Conn
|
||||
wctx context.Context
|
||||
wcancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]chan cdpMessage
|
||||
|
||||
netMu sync.Mutex
|
||||
inflight int // requêtes réseau en cours (pour l'attente d'inactivité)
|
||||
lastNet time.Time // dernier événement réseau
|
||||
|
||||
lastSnap string // dernier snapshot renvoyé, pour dé-dupliquer les états identiques
|
||||
}
|
||||
|
||||
type cdpMessage struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *cdpError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cdpError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ─── découverte du binaire navigateur ────────────────────────────────────────
|
||||
|
||||
// chromePath localise un binaire Chromium/Chrome/Edge exploitable. LOKI_CHROME
|
||||
// force un chemin ; sinon on cherche D'ABORD le Chromium de Playwright (celui
|
||||
// qu'embarque l'image Docker, sous PLAYWRIGHT_BROWSERS_PATH), puis les
|
||||
// emplacements standards par OS, puis le PATH.
|
||||
//
|
||||
// L'ordre compte : dans le conteneur, AUCUN chromium n'est installé par le
|
||||
// gestionnaire de paquets — le seul navigateur présent est celui que
|
||||
// `playwright install chromium` a déposé dans /opt/pw-browsers. Sans cette
|
||||
// recherche, le pilotage de navigateur serait déclaré indisponible dans l'image
|
||||
// même qui embarque un navigateur (voir Dockerfile, ARG PLAYWRIGHT).
|
||||
func chromePath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("LOKI_CHROME")); p != "" {
|
||||
return p
|
||||
}
|
||||
if p := playwrightChromium(); p != "" {
|
||||
return p
|
||||
}
|
||||
var cands []string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LocalAppData")} {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
cands = append(cands,
|
||||
filepath.Join(base, `Google\Chrome\Application\chrome.exe`),
|
||||
filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`),
|
||||
filepath.Join(base, `Chromium\Application\chrome.exe`),
|
||||
)
|
||||
}
|
||||
case "darwin":
|
||||
cands = []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
}
|
||||
default:
|
||||
for _, n := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c != "" {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"chrome", "google-chrome", "chromium"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// playwrightChromium renvoie le binaire Chromium déposé par Playwright, ou ""
|
||||
// s'il n'y en a pas. Playwright range ses navigateurs sous
|
||||
// PLAYWRIGHT_BROWSERS_PATH (l'image Docker fixe /opt/pw-browsers) ou, à défaut,
|
||||
// dans le cache utilisateur, en un dossier chromium-<build> par version.
|
||||
//
|
||||
// On prend la version la PLUS RÉCENTE (les builds sont numérotés, tri croissant
|
||||
// sur le nom) : une image reconstruite laisse parfois l'ancien dossier derrière
|
||||
// elle, et lancer un vieux Chromium sur le CDP d'aujourd'hui, c'est chercher
|
||||
// des pannes sans raison. `chrome` d'abord, `headless_shell` en repli : ce
|
||||
// dernier ne sait pas ouvrir de fenêtre (LOKI_CU_HEADFUL serait sans effet)
|
||||
// mais pilote très bien une page en headless.
|
||||
func playwrightChromium() string {
|
||||
var roots []string
|
||||
if p := strings.TrimSpace(os.Getenv("PLAYWRIGHT_BROWSERS_PATH")); p != "" && p != "0" {
|
||||
roots = append(roots, p)
|
||||
}
|
||||
roots = append(roots, "/opt/pw-browsers")
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
roots = append(roots, filepath.Join(home, "AppData", "Local", "ms-playwright"))
|
||||
case "darwin":
|
||||
roots = append(roots, filepath.Join(home, "Library", "Caches", "ms-playwright"))
|
||||
default:
|
||||
roots = append(roots, filepath.Join(home, ".cache", "ms-playwright"))
|
||||
}
|
||||
}
|
||||
// Chemin du binaire À L'INTÉRIEUR d'un dossier chromium-<build>, par OS.
|
||||
var rels [][]string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
rels = [][]string{{"chrome-win", "chrome.exe"}, {"chrome-win", "headless_shell.exe"}}
|
||||
case "darwin":
|
||||
rels = [][]string{{"chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"}, {"chrome-mac", "headless_shell"}}
|
||||
default:
|
||||
rels = [][]string{{"chrome-linux", "chrome"}, {"chrome-linux", "headless_shell"}}
|
||||
}
|
||||
for _, root := range roots {
|
||||
dirs, err := filepath.Glob(filepath.Join(root, "chromium*-*"))
|
||||
if err != nil || len(dirs) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
for _, rel := range rels {
|
||||
p := filepath.Join(append([]string{dirs[i]}, rel...)...)
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── lancement + connexion ───────────────────────────────────────────────────
|
||||
|
||||
func cdpLaunch() (*cdpSession, error) {
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
return nil, fmt.Errorf("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=<chemin du binaire>")
|
||||
}
|
||||
// Balaye les profils temporaires abandonnés (Chrome tué sans close() lors d'un
|
||||
// redémarrage du service) : sinon /tmp se remplit de dossiers loki-cu-* au fil
|
||||
// des restarts.
|
||||
sweepStaleCUDirs()
|
||||
userDir, err := os.MkdirTemp("", "loki-cu-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Headless par défaut (marche sur le serveur sans écran) ; LOKI_CU_HEADFUL=1
|
||||
// ouvre une vraie fenêtre visible sur une machine de bureau.
|
||||
args := []string{
|
||||
"--remote-debugging-port=0",
|
||||
"--user-data-dir=" + userDir,
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-gpu", "--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-features=Translate,BackForwardCache",
|
||||
// Indispensables sur un serveur headless sous un compte non-root : sans
|
||||
// --no-sandbox Chrome plante au lancement (namespaces refusés), et /dev/shm
|
||||
// y est souvent minuscule → --disable-dev-shm-usage évite les crash onglet.
|
||||
"--no-sandbox", "--disable-dev-shm-usage",
|
||||
"--window-size=1280,900",
|
||||
"about:blank",
|
||||
}
|
||||
if os.Getenv("LOKI_CU_HEADFUL") == "" {
|
||||
args = append([]string{"--headless=new"}, args...)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
// Chrome écrit le port de debug réel dans DevToolsActivePort (ligne 1).
|
||||
port := ""
|
||||
portFile := filepath.Join(userDir, "DevToolsActivePort")
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(portFile); err == nil {
|
||||
if line := strings.SplitN(strings.TrimSpace(string(b)), "\n", 2)[0]; line != "" {
|
||||
port = line
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if port == "" {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, fmt.Errorf("Chrome n'a pas exposé son port de debug (DevToolsActivePort)")
|
||||
}
|
||||
wsURL, err := cdpPageWS(port)
|
||||
if err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
c.SetReadLimit(-1) // les screenshots dépassent la limite par défaut (32 Kio)
|
||||
s := &cdpSession{cmd: cmd, userDir: userDir, ws: c, wctx: ctx, wcancel: cancel, pending: map[int]chan cdpMessage{}}
|
||||
go s.readLoop()
|
||||
_, _ = s.call("Page.enable", nil)
|
||||
_, _ = s.call("Runtime.enable", nil)
|
||||
_, _ = s.call("DOM.enable", nil)
|
||||
_, _ = s.call("Network.enable", nil) // pour l'attente d'inactivité réseau (settle)
|
||||
// Viewport DÉTERMINISTE : sans ça, headless donne une taille bizarre (mesuré :
|
||||
// 1264x749) que le modèle interprétait mal pour browser_click_xy (il visait trop
|
||||
// bas). On force exactement cuViewW x cuViewH, DPR 1 → le screenshot fait cette
|
||||
// taille, et les coordonnées lues dessus = coordonnées de clic, 1:1.
|
||||
_, _ = s.call("Emulation.setDeviceMetricsOverride", map[string]any{
|
||||
"width": cuViewW, "height": cuViewH, "deviceScaleFactor": 1, "mobile": false,
|
||||
})
|
||||
// Garder toute navigation dans le MÊME onglet : le pilote n'est attaché qu'à une
|
||||
// cible « page ». Un lien target=_blank ou un window.open ouvrait un onglet que
|
||||
// le pilote ne voyait pas → l'IA croyait que « rien ne se passe » et bouclait
|
||||
// (vécu sur le test IONOS « Devenez client »). Ce script, réévalué au début de
|
||||
// CHAQUE document, neutralise les nouveaux onglets.
|
||||
_, _ = s.call("Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": cdpSameTabJS})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpSameTabJS force les navigations « nouvel onglet » à rester dans l'onglet
|
||||
// courant (window.open redirigé, target=_blank retiré au clic).
|
||||
const cdpSameTabJS = `(function(){try{
|
||||
window.open=function(u){if(u)location.href=u;return window;};
|
||||
document.addEventListener('click',function(e){
|
||||
var a=e.target&&e.target.closest&&e.target.closest('a[target]');
|
||||
if(a)a.removeAttribute('target');
|
||||
},true);
|
||||
}catch(e){}})();`
|
||||
|
||||
// cdpPageWS interroge l'API HTTP de debug pour trouver l'URL websocket d'une
|
||||
// cible de type « page ».
|
||||
func cdpPageWS(port string) (string, error) {
|
||||
cl := &http.Client{Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := cl.Get("http://127.0.0.1:" + port + "/json")
|
||||
if err == nil {
|
||||
var targets []struct {
|
||||
Type string `json:"type"`
|
||||
WS string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&targets)
|
||||
resp.Body.Close()
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && t.WS != "" {
|
||||
return t.WS, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
return "", fmt.Errorf("aucune cible 'page' exposée par Chrome")
|
||||
}
|
||||
|
||||
func (s *cdpSession) readLoop() {
|
||||
for {
|
||||
_, data, err := s.ws.Read(s.wctx)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
for _, ch := range s.pending {
|
||||
close(ch)
|
||||
}
|
||||
s.pending = map[int]chan cdpMessage{}
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
var m cdpMessage
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
continue
|
||||
}
|
||||
if m.ID != 0 {
|
||||
s.mu.Lock()
|
||||
ch := s.pending[m.ID]
|
||||
delete(s.pending, m.ID)
|
||||
s.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Événements réseau : on suit le nombre de requêtes en vol pour que settle()
|
||||
// puisse attendre l'inactivité réseau (fin du chargement AJAX).
|
||||
switch m.Method {
|
||||
case "Network.requestWillBeSent":
|
||||
s.netMu.Lock()
|
||||
s.inflight++
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
case "Network.loadingFinished", "Network.loadingFailed":
|
||||
s.netMu.Lock()
|
||||
if s.inflight > 0 {
|
||||
s.inflight--
|
||||
}
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call envoie une commande CDP et attend sa réponse (borné à 30 s).
|
||||
func (s *cdpSession) call(method string, params map[string]any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
ch := make(chan cdpMessage, 1)
|
||||
s.pending[id] = ch
|
||||
s.mu.Unlock()
|
||||
|
||||
payload := map[string]any{"id": id, "method": method}
|
||||
if params != nil {
|
||||
payload["params"] = params
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
wctx, cancel := context.WithTimeout(s.wctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if err := s.ws.Write(wctx, websocket.MessageText, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case m, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connexion CDP fermée")
|
||||
}
|
||||
if m.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", method, m.Error.Message)
|
||||
}
|
||||
return m.Result, nil
|
||||
case <-wctx.Done():
|
||||
return nil, fmt.Errorf("%s: délai dépassé", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.ws != nil {
|
||||
_ = s.ws.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
if s.wcancel != nil {
|
||||
s.wcancel()
|
||||
}
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
if s.userDir != "" {
|
||||
os.RemoveAll(s.userDir)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpGet renvoie la session courante, en (re)lançant Chrome au besoin. Un ping
|
||||
// léger valide qu'une session existante répond encore.
|
||||
func cdpGet() (*cdpSession, error) {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
if _, err := cdpCur.call("Runtime.evaluate", map[string]any{"expression": "1", "returnByValue": true}); err == nil {
|
||||
return cdpCur, nil
|
||||
}
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
s, err := cdpLaunch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdpCur = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpShutdown ferme la session navigateur si elle existe (appelé quand on coupe
|
||||
// le computer use, pour ne pas laisser un Chrome headless traîner).
|
||||
// sweepStaleCUDirs supprime les profils Chrome temporaires (loki-cu-*) de plus
|
||||
// d'une heure laissés dans le dossier temp — best-effort, silencieux.
|
||||
func sweepStaleCUDirs() {
|
||||
entries, err := os.ReadDir(os.TempDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), "loki-cu-") {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
os.RemoveAll(filepath.Join(os.TempDir(), e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cdpShutdown() {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── évaluation JS ───────────────────────────────────────────────────────────
|
||||
|
||||
// cdpEval exécute une expression JS dans la page et renvoie sa valeur (JSON brut).
|
||||
func (s *cdpSession) eval(expr string) (json.RawMessage, error) {
|
||||
res, err := s.call("Runtime.evaluate", map[string]any{
|
||||
"expression": expr,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Result struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
} `json:"result"`
|
||||
ExceptionDetails *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"exceptionDetails"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &r)
|
||||
if r.ExceptionDetails != nil {
|
||||
return nil, fmt.Errorf("JS: %s", r.ExceptionDetails.Text)
|
||||
}
|
||||
return r.Result.Value, nil
|
||||
}
|
||||
|
||||
// ─── snapshot d'accessibilité (set-of-marks) ─────────────────────────────────
|
||||
|
||||
// cdpHelpersJS : fonctions partagées par le snapshot et browser_find, préfixées au
|
||||
// script évalué. Points clés :
|
||||
// - ajActionable ne garde que les éléments RÉELLEMENT cliquables/saisissables :
|
||||
// on écarte les conteneurs (list, listitem, banner, navigation, menu, region,
|
||||
// div/span muets…) qui polluaient la liste et embrouillaient le modèle.
|
||||
// - ajName enrichit un champ de formulaire avec son <label> (« Prénom » plutôt
|
||||
// que « firstName »), et NE RÉVÈLE PAS la valeur d'un champ mot de passe
|
||||
// (le secret ne doit pas repartir dans le contexte/l'export).
|
||||
const cdpHelpersJS = `
|
||||
function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;}
|
||||
function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);}
|
||||
function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;}
|
||||
function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;}
|
||||
var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]';
|
||||
// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics,
|
||||
// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le
|
||||
// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll /
|
||||
// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert.
|
||||
function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;i<e.length;i++)out.push(e[i]);var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);return out;}
|
||||
function ajByRef(ref){function f(r){var e=r.querySelector('[data-loki-ref="'+ref+'"]');if(e)return e;var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++)if(a[i].shadowRoot){var x=f(a[i].shadowRoot);if(x)return x;}return null;}return f(document);}
|
||||
function ajClear(){function w(r){var o=r.querySelectorAll('[data-loki-ref]');for(var i=0;i<o.length;i++)o[i].removeAttribute('data-loki-ref');var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);}
|
||||
`
|
||||
|
||||
// cdpSnapshotJS marque chaque élément ACTIONNABLE et VISIBLE d'un attribut
|
||||
// data-loki-ref numéroté, mémorise le centre de chacun dans window.__lokiRefs,
|
||||
// et renvoie {title,url,refs,above,below}. Compact volontairement.
|
||||
const cdpSnapshotJS = cdpHelpersJS + `(function(){
|
||||
ajClear();
|
||||
var els=ajQueryAll().filter(function(el){return ajActionable(el)&&ajVis(el);});
|
||||
var map={},refs=[],i=0;
|
||||
for(var k=0;k<els.length&&i<200;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
i++;var r=el.getBoundingClientRect();map[i]={x:r.left+r.width/2,y:r.top+r.height/2};
|
||||
el.setAttribute('data-loki-ref',String(i));
|
||||
refs.push('['+i+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
window.__lokiRefs=map;
|
||||
var de=document.documentElement;
|
||||
var sh=Math.max(de.scrollHeight,document.body?document.body.scrollHeight:0);
|
||||
return {title:document.title||'',url:location.href,refs:refs,
|
||||
above:Math.round(scrollY),below:Math.round(Math.max(0,sh-scrollY-innerHeight))};
|
||||
})()`
|
||||
|
||||
type cuSnap struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Refs []string `json:"refs"`
|
||||
Above int `json:"above"` // pixels défilables au-dessus de la vue
|
||||
Below int `json:"below"` // pixels défilables en dessous de la vue
|
||||
}
|
||||
|
||||
// snapshot lit l'arbre interactif courant et le formate pour le modèle.
|
||||
func (s *cdpSession) snapshot() (string, error) {
|
||||
val, err := s.eval(cdpSnapshotJS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var snap cuSnap
|
||||
if err := json.Unmarshal(val, &snap); err != nil {
|
||||
return "", fmt.Errorf("snapshot illisible: %v", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n%s\n\n", snap.Title, snap.URL)
|
||||
if len(snap.Refs) == 0 {
|
||||
b.WriteString("(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%d élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n", len(snap.Refs))
|
||||
b.WriteString(strings.Join(snap.Refs, "\n"))
|
||||
}
|
||||
// Indice de défilement : la liste ne couvre QUE la partie visible. Sans ce
|
||||
// repère, le modèle croit la page épuisée après un scroll (3 éléments restants)
|
||||
// et se met à deviner des URLs au lieu de défiler (vécu sur le test IONOS).
|
||||
if snap.Below > 20 || snap.Above > 20 {
|
||||
b.WriteString("\n\n")
|
||||
if snap.Below > 20 {
|
||||
fmt.Fprintf(&b, "↓ encore ~%d px sous la vue — browser_scroll(down) pour révéler d'autres éléments. ", snap.Below)
|
||||
}
|
||||
if snap.Above > 20 {
|
||||
fmt.Fprintf(&b, "↑ ~%d px au-dessus — browser_scroll(up) pour y revenir.", snap.Above)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), " "), nil
|
||||
}
|
||||
|
||||
// snapshotDedup renvoie le snapshot courant, ou une ligne courte s'il est
|
||||
// RIGOUREUSEMENT identique au dernier renvoyé (clic sans effet, ré-ouverture de
|
||||
// la même page…). Évite de re-lister 20+ éléments pour rien — les numéros restent
|
||||
// valides puisque le DOM n'a pas changé. Économie de contexte pour petits modèles.
|
||||
func (s *cdpSession) snapshotDedup() (string, error) {
|
||||
full, err := s.snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if full == s.lastSnap {
|
||||
return "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", nil
|
||||
}
|
||||
s.lastSnap = full
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// cdpFindJS cherche, sur TOUTE la page (pas seulement le viewport), les éléments
|
||||
// interactifs dont le rôle ou le nom contient la requête, leur pose un
|
||||
// data-loki-ref numéroté, fait défiler le premier au centre, et renvoie la liste.
|
||||
// Sert à localiser un lien/bouton hors écran sans défiler à l'aveugle.
|
||||
// browser_find est NON DESTRUCTIF (contrairement au snapshot) : il n'efface PAS les
|
||||
// data-loki-ref existants — sinon les numéros du dernier snapshot deviendraient
|
||||
// invalides et un browser_click ensuite échouerait (« introuvable », vécu au test #11).
|
||||
// Il RÉUTILISE le ref d'un élément déjà numéroté, et n'attribue un NOUVEAU numéro
|
||||
// (au-delà du max courant) qu'aux éléments hors écran encore sans ref.
|
||||
const cdpFindJS = `function(q){
|
||||
q=(q||'').toLowerCase();
|
||||
var pool=ajQueryAll();
|
||||
var maxRef=0;for(var m=0;m<pool.length;m++){var v=parseInt(pool[m].getAttribute('data-loki-ref'),10)||0;if(v>maxRef)maxRef=v;}
|
||||
var els=pool.filter(ajActionable);
|
||||
var refs=[],count=0,first=null;
|
||||
for(var k=0;k<els.length&&count<60;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
if((rl+' '+nm).toLowerCase().indexOf(q)<0)continue;
|
||||
var st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden')continue;
|
||||
var r=el.getBoundingClientRect();if(r.width<1&&r.height<1)continue;
|
||||
var ref=el.getAttribute('data-loki-ref');
|
||||
if(!ref){maxRef++;ref=String(maxRef);el.setAttribute('data-loki-ref',ref);}
|
||||
if(!first)first=el;count++;
|
||||
refs.push('['+ref+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
if(first)first.scrollIntoView({block:'center'});
|
||||
return {refs:refs};
|
||||
}`
|
||||
|
||||
func (s *cdpSession) find(query string) (string, error) {
|
||||
qb, _ := json.Marshal(query)
|
||||
val, err := s.eval(cdpHelpersJS + "(" + cdpFindJS + ")(" + string(qb) + ")")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
Refs []string `json:"refs"`
|
||||
}
|
||||
if err := json.Unmarshal(val, &r); err != nil {
|
||||
return "", fmt.Errorf("recherche illisible: %v", err)
|
||||
}
|
||||
if len(r.Refs) == 0 {
|
||||
return fmt.Sprintf("Aucun élément contenant « %s » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien).", query), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d élément(s) contenant « %s » (le 1er est amené dans la vue) — clique par numéro :\n", len(r.Refs), query)
|
||||
b.WriteString(strings.Join(r.Refs, "\n"))
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ─── actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// waitLoad attend (au mieux) que la page ait fini de charger après une navigation,
|
||||
// puis que le DOM se STABILISE (contenu rendu en AJAX après readyState=complete).
|
||||
func (s *cdpSession) waitLoad() {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
val, err := s.eval(`document.readyState`)
|
||||
if err == nil && strings.Trim(string(val), `"`) == "complete" {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
s.settle()
|
||||
}
|
||||
|
||||
// settle attend l'INACTIVITÉ RÉSEAU : sur une SPA, readyState passe à « complete »
|
||||
// avant que le contenu chargé en AJAX (résultats de recherche, étape de tunnel…)
|
||||
// ne soit arrivé. On rend la main dès qu'il n'y a plus de requête en vol depuis
|
||||
// ~600 ms, borné à 6 s. Ça supprime le « bash sleep » que le modèle bricolait
|
||||
// avant un browser_snapshot (vécu au test IONOS #10).
|
||||
func (s *cdpSession) settle() {
|
||||
const quiet = 600 * time.Millisecond
|
||||
deadline := time.Now().Add(6 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.netMu.Lock()
|
||||
inflight := s.inflight
|
||||
since := time.Since(s.lastNet)
|
||||
s.netMu.Unlock()
|
||||
if inflight <= 0 && since >= quiet {
|
||||
return
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) navigate(url string) error {
|
||||
// Toute navigation = nouvelle page : on oublie le dernier snapshot pour que le
|
||||
// browser_open qui suit renvoie la page COMPLÈTE, jamais « (page inchangée) »
|
||||
// (piège quand la session persiste d'un test à l'autre, vécu sur Lady Sushi).
|
||||
s.lastSnap = ""
|
||||
// « back » / « forward » : navigation dans l'historique (récupération d'erreur),
|
||||
// pas une URL — sinon on en ferait « https://back ».
|
||||
switch strings.ToLower(strings.TrimSpace(url)) {
|
||||
case "back":
|
||||
_, err := s.eval("history.back();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
case "forward":
|
||||
_, err := s.eval("history.forward();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(url, "://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
res, err := s.call("Page.navigate", map[string]any{"url": url})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Page.navigate signale l'échec réseau dans errorText (DNS, connexion refusée…) ;
|
||||
// et une page d'erreur Chrome a une location « chrome-error:// ». Dans les deux
|
||||
// cas on renvoie une erreur EXPLICITE plutôt qu'un faux snapshot « Actualiser »
|
||||
// que le modèle re-tentait en boucle (vécu : account.ionos.com ré-ouvert 2×).
|
||||
var nr struct {
|
||||
ErrorText string `json:"errorText"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &nr)
|
||||
s.waitLoad()
|
||||
loc, _ := s.eval("location.href")
|
||||
// ERR_ABORTED est bénin (redirection/téléchargement) : on ne le traite pas comme
|
||||
// un échec. Le signal fiable est une location « chrome-error:// ».
|
||||
failed := strings.Contains(string(loc), "chrome-error")
|
||||
if nr.ErrorText != "" && nr.ErrorText != "net::ERR_ABORTED" {
|
||||
failed = true
|
||||
}
|
||||
if failed {
|
||||
reason := nr.ErrorText
|
||||
if reason == "" {
|
||||
reason = "page injoignable"
|
||||
}
|
||||
return fmt.Errorf("chargement de %s échoué (%s) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", url, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refCenter renvoie les coordonnées viewport (fraîches) d'un élément marqué, en
|
||||
// le faisant d'abord défiler au centre.
|
||||
func (s *cdpSession) refCenter(ref int) (float64, float64, error) {
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(){var el=ajByRef(%d);if(!el){return null;}el.scrollIntoView({block:'center',inline:'center'});var r=el.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`, ref)
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if string(val) == "null" || len(val) == 0 {
|
||||
return 0, 0, fmt.Errorf("élément [%d] introuvable — refais un browser_snapshot (la page a peut-être changé)", ref)
|
||||
}
|
||||
var p struct{ X, Y float64 }
|
||||
if err := json.Unmarshal(val, &p); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return p.X, p.Y, nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) clickRef(ref int) error {
|
||||
x, y, err := s.refCenter(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickXY clique à des coordonnées pixel du VIEWPORT (lues sur le screenshot).
|
||||
// Seul moyen d'atteindre ce que le DOM ne voit pas : un bandeau de consentement
|
||||
// dans un iframe cross-origin, un canvas, une carte (vécu sur Lady Sushi).
|
||||
func (s *cdpSession) clickXY(x, y float64) error {
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) typeText(text string) error {
|
||||
_, err := s.call("Input.insertText", map[string]any{"text": text})
|
||||
return err
|
||||
}
|
||||
|
||||
// typeInto écrit dans le champ [ref]. Cas spécial des listes déroulantes NATIVES
|
||||
// (<select>, rôle « combobox ») : leur menu s'ouvre en natif hors du DOM, donc
|
||||
// ses options ne sont pas cliquables dans le snapshot (galère du test IONOS #14).
|
||||
// Ici on SÉLECTIONNE directement l'option dont le texte contient la valeur, et on
|
||||
// émet input/change. Sinon on focus+sélectionne le contenu et on laisse insertText
|
||||
// remplacer (comportement texte normal).
|
||||
func (s *cdpSession) typeInto(ref int, text string) error {
|
||||
vb, _ := json.Marshal(text)
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(ref,val){
|
||||
var el=ajByRef(ref);
|
||||
if(!el)return 'NOTFOUND';
|
||||
el.scrollIntoView({block:'center'});
|
||||
if(el.tagName.toLowerCase()==='select'){
|
||||
var o=el.options,lv=val.toLowerCase(),k=-1;
|
||||
for(var i=0;i<o.length;i++){var tx=(o[i].text||'').toLowerCase();if(tx===lv||tx.indexOf(lv)>=0||(o[i].value||'').toLowerCase()===lv){k=i;break;}}
|
||||
if(k<0)return 'NOOPT';
|
||||
el.selectedIndex=k;el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return 'SELECT';
|
||||
}
|
||||
el.focus();
|
||||
try{if(typeof el.select==='function')el.select();else if(window.getSelection){var r=document.createRange();r.selectNodeContents(el);var s=getSelection();s.removeAllRanges();s.addRange(r);}}catch(e){}
|
||||
return 'TEXT';
|
||||
})(%d,%s)`, ref, string(vb))
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.Trim(string(val), `"`) {
|
||||
case "NOTFOUND":
|
||||
return fmt.Errorf("champ [%d] introuvable — refais un browser_snapshot", ref)
|
||||
case "NOOPT":
|
||||
return fmt.Errorf("aucune option de la liste [%d] ne correspond à « %s » — fais un browser_screenshot pour voir les choix", ref, text)
|
||||
case "SELECT":
|
||||
return nil // liste déroulante réglée, rien à taper
|
||||
default: // TEXT
|
||||
return s.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpKeys mappe les touches nommées vers leur description CDP.
|
||||
var cdpKeys = map[string]struct {
|
||||
key, code string
|
||||
vk int
|
||||
}{
|
||||
"enter": {"Enter", "Enter", 13},
|
||||
"tab": {"Tab", "Tab", 9},
|
||||
"backspace": {"Backspace", "Backspace", 8},
|
||||
"escape": {"Escape", "Escape", 27},
|
||||
"esc": {"Escape", "Escape", 27},
|
||||
"delete": {"Delete", "Delete", 46},
|
||||
"up": {"ArrowUp", "ArrowUp", 38},
|
||||
"down": {"ArrowDown", "ArrowDown", 40},
|
||||
"left": {"ArrowLeft", "ArrowLeft", 37},
|
||||
"right": {"ArrowRight", "ArrowRight", 39},
|
||||
"home": {"Home", "Home", 36},
|
||||
"end": {"End", "End", 35},
|
||||
"pageup": {"PageUp", "PageUp", 33},
|
||||
"pagedown": {"PageDown", "PageDown", 34},
|
||||
}
|
||||
|
||||
func (s *cdpSession) pressKey(name string) error {
|
||||
k, ok := cdpKeys[strings.ToLower(strings.TrimSpace(name))]
|
||||
if !ok {
|
||||
return fmt.Errorf("touche inconnue « %s » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown", name)
|
||||
}
|
||||
for _, typ := range []string{"rawKeyDown", "keyUp"} {
|
||||
if _, err := s.call("Input.dispatchKeyEvent", map[string]any{
|
||||
"type": typ, "key": k.key, "code": k.code,
|
||||
"windowsVirtualKeyCode": k.vk, "nativeVirtualKeyCode": k.vk,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) scroll(dir string) error {
|
||||
dy, dx := 0, 0
|
||||
switch strings.ToLower(strings.TrimSpace(dir)) {
|
||||
case "down", "":
|
||||
dy = 600
|
||||
case "up":
|
||||
dy = -600
|
||||
case "right":
|
||||
dx = 600
|
||||
case "left":
|
||||
dx = -600
|
||||
default:
|
||||
return fmt.Errorf("direction inconnue « %s » (up, down, left, right)", dir)
|
||||
}
|
||||
_, err := s.eval(fmt.Sprintf("window.scrollBy(%d,%d);'ok'", dx, dy))
|
||||
if err == nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// screenshot capture le viewport en PNG (octets décodés).
|
||||
func (s *cdpSession) screenshot() ([]byte, error) {
|
||||
res, err := s.call("Page.captureScreenshot", map[string]any{"format": "png"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(r.Data)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCDPSmoke pilote un vrai Chrome de bout en bout : lancement, navigation,
|
||||
// snapshot d'accessibilité, click, screenshot. Coûteux et dépendant d'un
|
||||
// navigateur installé → ne tourne QUE si LOKI_CU_SMOKE=1.
|
||||
func TestCDPSmoke(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if !strings.Contains(strings.ToLower(snap), "example") {
|
||||
t.Errorf("titre 'example' attendu dans le snapshot, absent")
|
||||
}
|
||||
if !strings.Contains(snap, "link") {
|
||||
t.Errorf("le lien 'More information' devrait apparaître comme [n] link")
|
||||
}
|
||||
// browser_find doit retrouver le lien par son texte.
|
||||
found, err := s.find("more")
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(found), "link") {
|
||||
t.Errorf("browser_find('more') devrait trouver le lien, got: %s", found)
|
||||
}
|
||||
// Le screenshot doit produire des octets PNG non vides.
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
t.Fatalf("screenshot: %v", err)
|
||||
}
|
||||
if len(png) < 100 {
|
||||
t.Errorf("screenshot suspicieusement petit: %d octets", len(png))
|
||||
}
|
||||
t.Logf("screenshot: %d octets", len(png))
|
||||
}
|
||||
|
||||
// TestCDPTypeReplacesField vérifie le fix du test IONOS #4 : browser_type(ref) cible le
|
||||
// BON champ et REMPLACE son contenu, même avec deux champs et une valeur existante.
|
||||
func TestCDPTypeReplacesField(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Deux champs texte injectés dans la page courante ; le 2e contient « ancien ».
|
||||
if _, err := s.eval(`document.body.innerHTML='<input id=a placeholder=prenom><input id=b value=ancien>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Retrouve le numéro du 2e champ (celui à remplacer).
|
||||
val, err := s.eval(`document.querySelector('#b').getAttribute('data-loki-ref')`)
|
||||
if err != nil {
|
||||
t.Fatalf("eval ref: %v", err)
|
||||
}
|
||||
ref, _ := strconv.Atoi(strings.Trim(string(val), `"`))
|
||||
if ref == 0 {
|
||||
t.Fatalf("pas de ref sur #b (snapshot: %s)", val)
|
||||
}
|
||||
if err := s.typeInto(ref, "nouveau"); err != nil {
|
||||
t.Fatalf("typeInto: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#b').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "nouveau" {
|
||||
t.Errorf("champ #b = %q, attendu \"nouveau\" (remplacement raté)", v)
|
||||
}
|
||||
// Le premier champ ne doit PAS avoir été touché.
|
||||
got2, _ := s.eval(`document.querySelector('#a').value`)
|
||||
if v := strings.Trim(string(got2), `"`); v != "" {
|
||||
t.Errorf("champ #a = %q, attendu vide (mauvais ciblage)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotHygiene vérifie que le snapshot (1) masque la valeur d'un champ
|
||||
// mot de passe et (2) ne liste pas les conteneurs non-actionnables (un <ul>/<li>).
|
||||
func TestCDPSnapshotHygiene(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
inject := `document.body.innerHTML='` +
|
||||
`<label for=pw>Mot de passe</label><input id=pw type=password value=SECRET123>` +
|
||||
`<label for=em>Email</label><input id=em type=text value="a@b.co">` +
|
||||
`<ul><li>bloc conteneur</li></ul>` +
|
||||
`<a href="#x">un lien</a>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if strings.Contains(snap, "SECRET123") {
|
||||
t.Error("la valeur du champ mot de passe ne doit PAS apparaître dans le snapshot")
|
||||
}
|
||||
if strings.Contains(snap, "bloc conteneur") {
|
||||
t.Error("un <li> conteneur ne doit pas figurer dans la liste des éléments actionnables")
|
||||
}
|
||||
if !strings.Contains(snap, "un lien") {
|
||||
t.Error("le lien actionnable devrait figurer dans le snapshot")
|
||||
}
|
||||
// Le champ mot de passe lui-même reste listé (par son label), juste sans valeur.
|
||||
if !strings.Contains(strings.ToLower(snap), "mot de passe") {
|
||||
t.Error("le champ mot de passe devrait apparaître par son label")
|
||||
}
|
||||
// Un champ texte non-secret DOIT montrer sa valeur (observabilité du formulaire).
|
||||
if !strings.Contains(snap, "a@b.co") {
|
||||
t.Error("la valeur d'un champ texte non-secret devrait apparaître dans le snapshot")
|
||||
}
|
||||
// Un <input type=text> doit être annoncé « textbox », pas « text » (qui ressemble
|
||||
// à du texte statique et faisait croire au modèle que c'était une étiquette).
|
||||
if !strings.Contains(snap, "textbox") {
|
||||
t.Error("un champ de saisie devrait avoir le rôle 'textbox'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPRefUniqueness reproduit le bug du test IONOS #9 : un snapshot doit
|
||||
// EFFACER les anciens data-loki-ref, sinon deux éléments partagent un numéro et
|
||||
// browser_type/browser_click visent le mauvais champ (querySelector prend le 1er du DOM).
|
||||
func TestCDPRefUniqueness(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>A</a><a href=#>B</a><a href=#>C</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Simule un attribut périmé laissé par un snapshot antérieur sur un AUTRE élément.
|
||||
if _, err := s.eval(`document.querySelectorAll('a')[2].setAttribute('data-loki-ref','1');'ok'`); err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
// Un nouveau snapshot doit repartir propre : chaque numéro sur UN SEUL élément.
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot2: %v", err)
|
||||
}
|
||||
dup, _ := s.eval(`document.querySelectorAll('[data-loki-ref="1"]').length`)
|
||||
if strings.TrimSpace(string(dup)) != "1" {
|
||||
t.Errorf("data-loki-ref=\"1\" devrait être unique, trouvé %s occurrence(s)", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSettleWaitsAsync vérifie que settle() attend le contenu injecté en
|
||||
// asynchrone (cas du test #10 où le modèle devait « bash sleep » avant snapshot).
|
||||
func TestCDPSettleWaitsAsync(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Page réelle (réseau réel), puis on déclenche un fetch AJAX qui n'ajoute son
|
||||
// lien qu'une fois la requête terminée. settle() doit attendre l'inactivité
|
||||
// réseau et donc voir le lien.
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
if _, err := s.eval(`fetch(location.href,{cache:'no-store'}).then(function(){var a=document.createElement('a');a.href='#';a.textContent='AjaxDone';document.body.appendChild(a);});'ok'`); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond) // laisse requestWillBeSent s'enregistrer
|
||||
s.settle() // doit attendre la fin du fetch
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "AjaxDone") {
|
||||
t.Errorf("settle() aurait dû attendre la fin du fetch AJAX; snapshot:\n%s", snap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPTypeSelectsOption vérifie le fix du test #14 : browser_type sur un <select>
|
||||
// choisit l'option par son texte au lieu de taper des caractères.
|
||||
func TestCDPTypeSelectsOption(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<select id=civ><option value="">--</option><option>Madame</option><option>Monsieur</option><option>Autre</option></select>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
ref, _ := s.eval(`+document.querySelector('#civ').getAttribute('data-loki-ref')`)
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(string(ref)))
|
||||
if n == 0 {
|
||||
t.Fatalf("pas de ref sur le select (%s)", ref)
|
||||
}
|
||||
if err := s.typeInto(n, "Monsieur"); err != nil {
|
||||
t.Fatalf("typeInto select: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#civ').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "Monsieur" {
|
||||
t.Errorf("le select devrait valoir « Monsieur », got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotDedup vérifie qu'un état identique renvoie une ligne courte au
|
||||
// lieu de re-lister tous les éléments (économie de contexte, test #12).
|
||||
func TestCDPSnapshotDedup(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Un lien</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
first, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup1: %v", err)
|
||||
}
|
||||
if !strings.Contains(first, "Un lien") {
|
||||
t.Fatalf("1er snapshot devrait lister le lien, got: %s", first)
|
||||
}
|
||||
second, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup2: %v", err)
|
||||
}
|
||||
if strings.Contains(second, "Un lien") || !strings.Contains(second, "inchang") {
|
||||
t.Errorf("2e snapshot identique devrait être compact (inchangé), got: %s", second)
|
||||
}
|
||||
// Après un vrai changement, on redonne la liste complète.
|
||||
if _, err := s.eval(`document.body.innerHTML+='<a href=#>Autre</a>';'ok'`); err != nil {
|
||||
t.Fatalf("change: %v", err)
|
||||
}
|
||||
third, _ := s.snapshotDedup()
|
||||
if !strings.Contains(third, "Autre") {
|
||||
t.Errorf("après changement, le snapshot complet devrait revenir, got: %s", third)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPFindKeepsRefs vérifie le fix du test #11 : browser_find ne doit PAS effacer
|
||||
// les data-loki-ref du dernier snapshot (sinon un browser_click ensuite échoue).
|
||||
func TestCDPFindKeepsRefs(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Alpha</a><a href=#>Beta</a><a href=#>Connexion</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
before, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
// Recherche d'un AUTRE élément : ne doit pas toucher au ref de « Connexion ».
|
||||
if _, err := s.find("Alpha"); err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
after, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
if string(before) != string(after) || strings.Trim(string(after), `"`) == "" {
|
||||
t.Errorf("browser_find a modifié/effacé le ref de « Connexion » : avant=%s après=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClickXY vérifie le clic par coordonnées (pour iframe cross-origin/canvas).
|
||||
func TestCDPClickXY(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un bouton à une position connue qui note son propre clic.
|
||||
inject := `document.body.innerHTML='<button id=b style="position:fixed;left:40px;top:30px;width:120px;height:40px">X</button>';` +
|
||||
`window.__hit=false;document.getElementById('b').addEventListener('click',function(){window.__hit=true;});'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if err := s.clickXY(100, 50); err != nil { // dans le rectangle du bouton
|
||||
t.Fatalf("clickXY: %v", err)
|
||||
}
|
||||
hit, _ := s.eval(`window.__hit`)
|
||||
if strings.TrimSpace(string(hit)) != "true" {
|
||||
t.Errorf("le clic par coordonnées aurait dû atteindre le bouton, got %s", hit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPShadowDOM vérifie le fix du test Lady Sushi : les éléments d'un shadow
|
||||
// DOM (bandeaux cookies, web components) sont vus par le snapshot ET cliquables.
|
||||
func TestCDPShadowDOM(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un host avec un bouton « TOUT REFUSER » DANS un shadow root ouvert.
|
||||
inject := `document.body.innerHTML='<div id=host></div>';` +
|
||||
`var sr=document.getElementById('host').attachShadow({mode:'open'});` +
|
||||
`sr.innerHTML='<button id=b>TOUT REFUSER</button>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "TOUT REFUSER") {
|
||||
t.Fatalf("le bouton du shadow DOM devrait apparaître dans le snapshot; got:\n%s", snap)
|
||||
}
|
||||
// browser_find doit aussi le trouver, et browser_click le viser (via ajByRef).
|
||||
found, err := s.find("refuser")
|
||||
if err != nil || !strings.Contains(strings.ToLower(found), "refuser") {
|
||||
t.Errorf("find n'a pas trouvé le bouton shadow: %v / %s", err, found)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPNavigateError vérifie qu'une URL injoignable renvoie une ERREUR claire
|
||||
// (et pas un faux snapshot avec un bouton « Actualiser » que le modèle re-tente).
|
||||
func TestCDPNavigateError(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
err = s.navigate("https://nonexistent-host-loki-test-99999.invalid")
|
||||
if err == nil {
|
||||
t.Error("une URL injoignable devrait renvoyer une erreur, pas nil")
|
||||
} else {
|
||||
t.Logf("erreur attendue: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestDedupableTool verrouille le fix du test IONOS : les outils à état vivant /
|
||||
// à image (cu_*, see_image, bash) ne doivent JAMAIS être dédupliqués, sinon un
|
||||
// second appel identique renvoie « [déjà fait] » sans rejouer l'action ni
|
||||
// l'image, et l'IA boucle.
|
||||
func TestDedupableTool(t *testing.T) {
|
||||
never := []string{"bash", "see_image", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_key", "browser_scroll", "browser_screenshot"}
|
||||
for _, n := range never {
|
||||
if dedupableTool(n) {
|
||||
t.Errorf("%s ne doit PAS être déduplicable", n)
|
||||
}
|
||||
}
|
||||
always := []string{"write", "edit", "mem_add", "web_open"}
|
||||
for _, n := range always {
|
||||
if !dedupableTool(n) {
|
||||
t.Errorf("%s devrait rester déduplicable", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package loki
|
||||
|
||||
// computer_use.go — le « computer use » : donner à Jean la capacité d'agir sur
|
||||
// une interface graphique de la machine hôte. Étape 1 : le WEB, via un Chromium
|
||||
// piloté en CDP (computer_cdp.go). Le modèle lit l'arbre d'accessibilité (des
|
||||
// éléments interactifs NUMÉROTÉS, façon set-of-marks) et agit par numéro —
|
||||
// aucune vision requise, ce qui marche même avec de petits modèles texte à
|
||||
// faible contexte. browser_screenshot n'est proposé que si la vision est active, pour
|
||||
// les cas sans a11y (canvas, jeux).
|
||||
//
|
||||
// C'est un axe de capacité indépendant, sous le mode agent (comme l'accès
|
||||
// internet) : un outil qui pilote un navigateur exécute des actions réelles, donc
|
||||
// même niveau de confiance que bash. Interrupteur : clé bkState "computer".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func computerUseEnabled() bool { return getBool(bkState, "computer") }
|
||||
|
||||
func setComputerUseEnabled(on bool) error { return putBool(bkState, "computer", on) }
|
||||
|
||||
// cuMaxOutput borne ce qu'un appel d'outil computer use injecte dans le contexte
|
||||
// (aligné sur le web et le shell : 8000).
|
||||
const cuMaxOutput = 8000
|
||||
|
||||
func capCUOutput(s string) string {
|
||||
if r := []rune(s); len(r) > cuMaxOutput {
|
||||
return string(r[:cuMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── schémas d'outils (annoncés au modèle) ───────────────────────────────────
|
||||
|
||||
func cuOpenTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_open",
|
||||
Description: "Open a URL in the controlled browser and return the page's interactive elements, each numbered [n]. Start here. Pass 'back' or 'forward' instead of a URL to move through history.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"url": map[string]any{"type": "string", "description": "URL to open, or 'back' / 'forward'"}},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuSnapshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_snapshot",
|
||||
Description: "Re-read the current page's interactive elements (numbered). Use it after the page changes to get fresh [n] refs.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click",
|
||||
Description: "Click the element with the given number from the latest snapshot. Returns the page state afterwards.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"ref": map[string]any{"type": "integer", "description": "Element number [n]"}},
|
||||
"required": []string{"ref"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuTypeTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_type",
|
||||
Description: "Type text into a field. ALWAYS give 'ref' (the field's number) so it targets the right field and REPLACES its current content — no need to browser_click the field first. For a dropdown/select (role 'combobox'), pass the option's text and it picks that option directly (don't click it open). Without a ref it types into whatever is focused, which is unreliable.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to type"},
|
||||
"ref": map[string]any{"type": "integer", "description": "Optional: field number [n] to focus first"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuKeyTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_key",
|
||||
Description: "Press a special key: enter, tab, backspace, escape, delete, up, down, left, right, home, end, pageup, pagedown.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"key": map[string]any{"type": "string", "description": "Key name"}},
|
||||
"required": []string{"key"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuFindTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_find",
|
||||
Description: "Find interactive elements ANYWHERE on the page (not just the visible part) whose label contains the text, and scroll the first into view. Use it to locate an off-screen link or button (e.g. 'créer un compte', 'panier') instead of scrolling blindly or guessing URLs.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"text": map[string]any{"type": "string", "description": "Text to look for in element labels"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScrollTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_scroll",
|
||||
Description: "Scroll the page (up, down, left, right), then return the now-visible interactive elements.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"direction": map[string]any{"type": "string", "description": "up, down, left or right"}},
|
||||
"required": []string{"direction"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickXYTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click_xy",
|
||||
Description: fmt.Sprintf("Click at pixel coordinates (x from the left, y from the top) read off the latest browser_screenshot — %dx%d pixels, 1:1 with the page, with a coordinate grid drawn every 100px to read x/y precisely. Use it ONLY for something the numbered elements can't reach: a cookie/consent banner inside a cross-origin iframe, a canvas, a map. Otherwise always prefer browser_click(ref).", cuViewW, cuViewH),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"x": map[string]any{"type": "number", "description": "pixels from the left edge"},
|
||||
"y": map[string]any{"type": "number", "description": "pixels from the top edge"},
|
||||
},
|
||||
"required": []string{"x", "y"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScreenshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_screenshot",
|
||||
Description: "Capture the current page as an image. It is shown to the user immediately AND saved to your working folder; the result gives the filename to link with Markdown if they want the file. Use it to see a visual layout the numbered elements don't convey, or when the user asks for a screenshot — never hunt the disk for it.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
// computerUseTools est la liste annoncée au modèle quand le computer use est
|
||||
// actif. browser_screenshot n'y figure que si la vision est réellement active.
|
||||
func computerUseTools() []Tool {
|
||||
tools := []Tool{cuOpenTool(), cuSnapshotTool(), cuFindTool(), cuClickTool(), cuTypeTool(), cuKeyTool(), cuScrollTool()}
|
||||
// Vision active : la capture d'écran ET le clic par coordonnées (qui n'a de sens
|
||||
// que si le modèle peut lire l'image pour viser).
|
||||
if visionEnabled() {
|
||||
tools = append(tools, cuScreenshotTool(), cuClickXYTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// cuPromptLine est la consigne d'usage ajoutée au prompt système quand le
|
||||
// computer use est actif (l'ordre d'appel que les schémas isolés ne disent pas).
|
||||
func cuPromptLine() string {
|
||||
line := "\nComputer use (a browser you control on this machine): browser_open a URL, then act on the NUMBERED elements — browser_click(ref), browser_type(text, ref), browser_key, browser_scroll. Each action returns the fresh numbered elements; after the page changes, trust the new numbers, not old ones. The list shows only what's in view: to reach an off-screen link or button (e.g. 'créer un compte'), use browser_find(text) to jump straight to it rather than scrolling blindly or, worse, guessing URLs — inventing paths mostly 404s. If a page shows an error (404, 'introuvable'), browser_open goes 'back' or try another link. Prefer the numbered elements over screenshots."
|
||||
if visionEnabled() {
|
||||
line += " Use browser_screenshot when the numbers aren't enough (canvas, visual layout). If a button is visible but NOT in the numbered list (e.g. a cookie banner inside an iframe), browser_screenshot then browser_click_xy(x,y) at its pixel position."
|
||||
}
|
||||
line += "\n"
|
||||
return line
|
||||
}
|
||||
|
||||
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
||||
|
||||
func toolCUOpen(args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return "[erreur] url manquante"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.navigate(url); err != nil {
|
||||
return "[erreur] navigation : " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUSnapshot() string {
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUFind(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "[erreur] texte de recherche manquant"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
res, err := s.find(text)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolCUClick(args map[string]any) string {
|
||||
ref, ok := intArg(args, "ref")
|
||||
if !ok {
|
||||
return "[erreur] ref manquant (numéro de l'élément)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickRef(ref); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] cliqué [%d]\n\n%s", ref, snap)
|
||||
}
|
||||
|
||||
func toolCUType(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if ref, ok := intArg(args, "ref"); ok {
|
||||
if err := s.typeInto(ref, text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
} else if err := s.typeText(text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] saisi « %s »\n\n%s", text, snap)
|
||||
}
|
||||
|
||||
func toolCUKey(args map[string]any) string {
|
||||
key, _ := args["key"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.pressKey(key); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] touche %s\n\n%s", key, snap)
|
||||
}
|
||||
|
||||
func toolCUClickXY(args map[string]any) string {
|
||||
x, okx := floatArg(args, "x")
|
||||
y, oky := floatArg(args, "y")
|
||||
if !okx || !oky {
|
||||
return "[erreur] coordonnées x et y requises (en pixels du screenshot)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickXY(x, y); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] clic en (%.0f, %.0f)\n\n%s", x, y, snap)
|
||||
}
|
||||
|
||||
func toolCUScroll(args map[string]any) string {
|
||||
dir, _ := args["direction"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.scroll(dir); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return snap
|
||||
}
|
||||
|
||||
// toolCUScreenshot renvoie (accusé texte, partie image_url) comme see_image :
|
||||
// l'image est réinjectée dans un message utilisateur multimodal par l'appelant.
|
||||
//
|
||||
// La capture PLEINE RÉSOLUTION est aussi ENREGISTRÉE dans le dossier de travail :
|
||||
// sans ça, le modèle qui reçoit « envoie-moi une capture » partait chercher un
|
||||
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
|
||||
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
|
||||
func toolCUScreenshot() (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
// Enregistre le PNG plein format dans le workspace (chemin RELATIF renvoyé au
|
||||
// modèle : un lien Markdown [capture](nom.png) est servi par /api/chat/file).
|
||||
saved := ""
|
||||
name := "capture-" + time.Now().Format("20060102-150405") + ".png"
|
||||
if ws := agentWorkspace(); ws != "" {
|
||||
if err := os.WriteFile(filepath.Join(ws, name), png, 0o644); err == nil {
|
||||
saved = name
|
||||
}
|
||||
}
|
||||
// L'image du MODÈLE porte une GRILLE de coordonnées (lignes graduées tous les
|
||||
// 100 px) pour qu'il lise x/y avec précision ; celle sauvegardée pour l'utilisateur
|
||||
// (plus haut, `png`) reste PROPRE, sans grille.
|
||||
b, mime := prepareImageForModel(overlayGrid(png), "image/png")
|
||||
// ⚠️ TOI (le modèle) vois l'image ci-dessous, mais l'utilisateur NE la voit PAS
|
||||
// automatiquement dans le chat. Pour la lui afficher, il faut l'insérer en IMAGE
|
||||
// Markdown  — un simple lien [texte](fichier) ne fait qu'un bouton
|
||||
// de téléchargement, pas d'aperçu.
|
||||
msg := fmt.Sprintf("[ok] capture prise (tu la vois ci-dessous, %dx%d px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille.", cuViewW, cuViewH)
|
||||
if saved != "" {
|
||||
msg += " Enregistrée sous « " + saved + " ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : . Un simple lien [capture](" + saved + ") ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci."
|
||||
}
|
||||
return msg, imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// intArg extrait un entier d'un argument d'outil (le JSON décode les nombres en
|
||||
// float64).
|
||||
func intArg(args map[string]any, key string) (int, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// floatArg extrait un nombre d'un argument d'outil (coordonnées de clic).
|
||||
func floatArg(args map[string]any, key string) (float64, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ─── CLI : loki computer [on|off|status] ────────────────────────────────────
|
||||
|
||||
func cmdComputer(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if err := setComputerUseEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := " contrôle du navigateur activé — l'IA pilote un navigateur (browser_open/browser_click/browser_type/…) si le mode agent est actif"
|
||||
if chromePath() == "" {
|
||||
msg += "\n" + red("[!]") + " aucun Chrome/Chromium/Edge détecté — installe-le ou définis LOKI_CHROME=<chemin>"
|
||||
}
|
||||
fmt.Println(green("[ok]") + msg)
|
||||
case "off":
|
||||
if err := setComputerUseEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
cdpShutdown()
|
||||
fmt.Println(green("[ok]") + " contrôle du navigateur désactivé")
|
||||
case "", "status":
|
||||
state := dim("off")
|
||||
if computerUseEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Contrôle du navigateur"), state)
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
fmt.Printf(" navigateur : %s — installe Chrome/Chromium/Edge ou LOKI_CHROME=<chemin>\n", red("introuvable"))
|
||||
} else {
|
||||
fmt.Printf(" navigateur : %s\n", bold(bin))
|
||||
}
|
||||
fmt.Printf(" outils : browser_open, browser_snapshot, browser_find, browser_click, browser_type, browser_key, browser_scroll")
|
||||
if visionEnabled() {
|
||||
fmt.Printf(", browser_screenshot, browser_click_xy")
|
||||
}
|
||||
fmt.Println()
|
||||
default:
|
||||
return fmt.Errorf("usage: loki computer [on|off|status]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+140
-17
@@ -202,6 +202,9 @@ type Caps struct {
|
||||
// "verifier" (passe de vérification, seule autorisée à marquer un critère
|
||||
// passed), "explorer", "code-reviewer". Voir code_roles.go.
|
||||
Role string
|
||||
// ComputerUse = pilotage du navigateur du conteneur (outils browser_*).
|
||||
// Requiert aussi Agent (mêmes actions réelles que bash). Voir computer_use.go.
|
||||
ComputerUse bool
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
@@ -219,7 +222,7 @@ func globalCaps() Caps {
|
||||
if !agent {
|
||||
return Caps{Agent: false, Internet: false, Mem: MemOff}
|
||||
}
|
||||
return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode()}
|
||||
return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode(), ComputerUse: computerUseEnabled()}
|
||||
}
|
||||
|
||||
// InjectSkills prepends context system messages to msgs: the decisive-agent
|
||||
@@ -327,16 +330,47 @@ func EnabledTools(caps Caps) []Tool {
|
||||
if caps.Code && caps.Role == "verifier" {
|
||||
return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()}
|
||||
}
|
||||
// Rôles DÉLÉGUÉS (outil subagent) : lecture seule, et rien d'autre. Ni
|
||||
// write/edit (ce qui modifie le dépôt reste dans le fil principal, sous les
|
||||
// yeux de l'utilisateur), ni critères (seule la passe de vérification les
|
||||
// marque), ni subagent (pas de récursion), ni mémoire ni web — chaque schéma
|
||||
// coûte du contexte, et un explorateur n'a que du code à lire.
|
||||
if caps.Code && isSubagentRole(caps.Role) {
|
||||
tools := []Tool{readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool()}
|
||||
if caps.Role == "planner" {
|
||||
// Le planificateur POSE le contrat (criteria action=add) : c'est la
|
||||
// moitié de son travail, et son prompt le lui demande. Marquer un
|
||||
// critère « passé » lui reste interdit — allowPass ne vaut que pour
|
||||
// la passe de vérification (voir toolCriteria).
|
||||
tools = append(tools, criteriaTool())
|
||||
}
|
||||
if caps.Role == "explorer" || caps.Role == "code-reviewer" {
|
||||
// bash : lancer un test, compter des occurrences. Les commandes
|
||||
// catastrophiques restent refusées (code_policy.go) et les chemins
|
||||
// bornés au dossier de la discussion.
|
||||
tools = append(tools, bashTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), writeTool(), editTool())
|
||||
// Mémoire longue de la conversation (chat_recall.go) : le compactage
|
||||
// n'archive des blocs QUE quand le mode agent est actif — annoncer recall
|
||||
// sans lui donnerait un outil qui ne peut rien trouver.
|
||||
tools = append(tools, recallTool(), recallSearchTool())
|
||||
// Tâches planifiées : l'IA se donne elle-même des rappels et des veilles
|
||||
// récurrentes, et gère les siennes (tasks_tools.go). Cloisonnées par
|
||||
// projet : dans un projet, elle ne voit et ne pilote QUE ses tâches.
|
||||
// Réservé au mode agent — une tâche sans outils pour agir n'aurait rien
|
||||
// à livrer.
|
||||
tools = append(tools, taskListTool(), taskCreateTool(), taskUpdateTool(), taskDeleteTool())
|
||||
}
|
||||
if caps.Code {
|
||||
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
|
||||
bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool())
|
||||
// Délégation à un rôle en contexte isolé (code_subagent.go). Réservée au
|
||||
// fil principal : un sous-agent ne délègue pas à son tour.
|
||||
tools = append(tools, subagentTool())
|
||||
}
|
||||
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
|
||||
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
|
||||
@@ -369,6 +403,14 @@ func EnabledTools(caps Caps) []Tool {
|
||||
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
// Pilotage de navigateur (browser_*) : l'IA ouvre une page, lit ses éléments
|
||||
// interactifs NUMÉROTÉS et agit par numéro. Ce sont des actions réelles sur
|
||||
// le web (cliquer, taper, valider un formulaire) : même niveau de confiance
|
||||
// que bash, donc mode agent requis, plus son propre interrupteur (Réglages).
|
||||
// Voir computer_use.go.
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
tools = append(tools, computerUseTools()...)
|
||||
}
|
||||
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
|
||||
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
|
||||
// agent. La découverte est paresseuse et cachée (voir mcp_client.go).
|
||||
@@ -438,24 +480,77 @@ type ToolUsedEvent struct {
|
||||
// pensait à recopier la ligne markdown rendue par l'outil : un petit modèle
|
||||
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
|
||||
Image string
|
||||
// ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en
|
||||
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle, qui affichait
|
||||
// sinon la taille de l'aperçu — toujours le même chiffre, quelle que soit la
|
||||
// page lue.
|
||||
ResultChars int
|
||||
// ResultID : identifiant (tool_call_id) avec lequel l'UI CHARGE le résultat
|
||||
// complet à la demande via /api/chat/tool-result. Le flux ne transporte que
|
||||
// l'aperçu ; le reste ne descend qu'au clic sur « voir plus ». Vide = Result
|
||||
// est déjà complet (rien à charger).
|
||||
ResultID string
|
||||
}
|
||||
|
||||
// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI.
|
||||
// Aligné sur le plus haut plafond côté modèle (mcpMaxOutput = 12000 ; shell et
|
||||
// web = 8000) : le modèle et l'UI voient donc la même chose, et l'étiquette
|
||||
// « ~N tok » de la bulle dit la VRAIE taille du résultat.
|
||||
// toolPreviewChars borne l'aperçu de résultat d'outil envoyé à l'UI. Le résultat
|
||||
// COMPLET reste côté serveur (conv.Messages) et se charge à la demande.
|
||||
//
|
||||
// Avant, cette borne était à 4000 : toute page web un peu longue s'affichait
|
||||
// « ~1004 tok » — la valeur du plafond, pas celle de la page. Le compteur
|
||||
// mentait, et il mentait toujours avec le même chiffre.
|
||||
const shownDisplayMax = 12000
|
||||
// Avant, l'UI recevait tout le résultat tronqué à 12000 caractères : chaque tour
|
||||
// poussait des dizaines de kilo-octets dans le flux SSE et dans le journal
|
||||
// persisté, pour un bloc que personne ne déplie une fois sur dix.
|
||||
const toolPreviewChars = 1600
|
||||
|
||||
// shownResult prépare un résultat d'outil pour l'affichage.
|
||||
func shownResult(s string) string {
|
||||
if r := []rune(s); len(r) > shownDisplayMax {
|
||||
return string(r[:shownDisplayMax]) + "\n…[tronqué]"
|
||||
// toolResultPreview renvoie (aperçu, taille réelle en runes, coupé ?).
|
||||
func toolResultPreview(s string) (string, int, bool) {
|
||||
r := []rune(s)
|
||||
if len(r) <= toolPreviewChars {
|
||||
return s, len(r), false
|
||||
}
|
||||
return s
|
||||
return string(r[:toolPreviewChars]), len(r), true
|
||||
}
|
||||
|
||||
// fillToolResult pose sur l'événement l'aperçu envoyé à l'UI, la taille réelle
|
||||
// (pour le compteur ~N tok) et, si le résultat est coupé et qu'on a un id, la
|
||||
// référence pour charger le reste à la demande. Sans id utilisable, on envoie le
|
||||
// résultat entier : mieux vaut un flux plus lourd qu'un bloc qu'on ne peut plus
|
||||
// déplier.
|
||||
func fillToolResult(ev *ToolUsedEvent, result, tcID string) *ToolUsedEvent {
|
||||
prev, n, cut := toolResultPreview(result)
|
||||
ev.ResultChars = n
|
||||
if cut && tcID != "" {
|
||||
ev.Result = prev
|
||||
ev.ResultID = tcID
|
||||
} else {
|
||||
ev.Result = result
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// dedupableTool indique si un appel RIGOUREUSEMENT identique (même outil, mêmes
|
||||
// arguments) doit être court-circuité au lieu d'être rejoué. Vrai pour les outils
|
||||
// où un ré-appel identique n'a pas de sens ou produit une fausse erreur (rejouer
|
||||
// une écriture déjà appliquée → « old introuvable »).
|
||||
//
|
||||
// FAUX pour bash : relancer la MÊME commande est un usage courant et légitime —
|
||||
// l'IA crée un fichier, le lance, le modifie, puis le RELANCE avec la même ligne ;
|
||||
// le résultat change parce que le fichier a changé. La dédup la bloquait par un
|
||||
// « [déjà fait] » exaspérant. bash a des effets de bord : on le laisse toujours
|
||||
// s'exécuter. Même raison pour bash_bg / bash_tail (suivre un job, c'est relire
|
||||
// la même sortie qui, elle, avance).
|
||||
//
|
||||
// FAUX aussi pour see_image et tout le pilotage de navigateur (browser_*) :
|
||||
// - leur résultat dépend de l'ÉTAT VIVANT de la page (un browser_snapshot sans
|
||||
// argument a forcément la même clé de dédup à chaque appel, alors que la page
|
||||
// a changé) — les dédupliquer fige l'IA sur un vieux cliché ;
|
||||
// - surtout, browser_screenshot et see_image portent leur IMAGE dans un message
|
||||
// à part (visionImg) que le chemin de dédup ne rejoue PAS : l'IA recevrait
|
||||
// « [déjà fait] » SANS l'image et tournerait en boucle.
|
||||
func dedupableTool(name string) bool {
|
||||
switch name {
|
||||
case "bash", "bash_bg", "bash_tail", "see_image":
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(name, "browser_")
|
||||
}
|
||||
|
||||
// repeatedCallResult construit ce qu'on renvoie quand le modèle redemande un
|
||||
@@ -1228,10 +1323,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// même écriture ; la rejouer produisait une fausse erreur (« old
|
||||
// introuvable », puisque le remplacement est déjà fait).
|
||||
callKey := tc.Function.Name + "\x00" + tc.Function.Arguments
|
||||
if prev, seen := doneCalls[callKey]; seen {
|
||||
if prev, seen := doneCalls[callKey]; seen && dedupableTool(tc.Function.Name) {
|
||||
repeatCount[callKey]++
|
||||
result = repeatedCallResult(prev, repeatCount[callKey])
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result, tc.ID)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
@@ -1349,6 +1444,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = toolGitClone(ctx, args)
|
||||
case "criteria":
|
||||
result = toolCriteria(args, caps.Role == "verifier")
|
||||
case "subagent":
|
||||
result = toolSubagent(ctx, args, caps)
|
||||
case "bash":
|
||||
to := 0
|
||||
switch v := args["timeout"].(type) {
|
||||
@@ -1381,6 +1478,32 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = capWebOutput(toolWebGrep(args))
|
||||
case "web_screenshot":
|
||||
result = toolWebScreenshot(args, caps)
|
||||
case "task_list":
|
||||
result = toolTaskList(args)
|
||||
case "task_create":
|
||||
result = toolTaskCreate(args)
|
||||
case "task_update":
|
||||
result = toolTaskUpdate(args)
|
||||
case "task_delete":
|
||||
result = toolTaskDelete(args)
|
||||
case "browser_open":
|
||||
result = capCUOutput(toolCUOpen(args))
|
||||
case "browser_snapshot":
|
||||
result = capCUOutput(toolCUSnapshot())
|
||||
case "browser_find":
|
||||
result = capCUOutput(toolCUFind(args))
|
||||
case "browser_click":
|
||||
result = capCUOutput(toolCUClick(args))
|
||||
case "browser_click_xy":
|
||||
result = capCUOutput(toolCUClickXY(args))
|
||||
case "browser_type":
|
||||
result = capCUOutput(toolCUType(args))
|
||||
case "browser_key":
|
||||
result = capCUOutput(toolCUKey(args))
|
||||
case "browser_scroll":
|
||||
result = capCUOutput(toolCUScroll(args))
|
||||
case "browser_screenshot":
|
||||
result, visionImg = toolCUScreenshot()
|
||||
default:
|
||||
if isMCPTool(tc.Function.Name) {
|
||||
result = mcpCall(tc.Function.Name, args)
|
||||
@@ -1397,7 +1520,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if tc.Function.Name == "web_screenshot" {
|
||||
shot = capturedRelPath(result)
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff, Image: shot}})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Image: shot}, result, tc.ID)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
|
||||
@@ -48,8 +48,6 @@ const (
|
||||
mcpConnectTimeoutCold = 3 * time.Minute
|
||||
// mcpCallTimeout borne un appel d'outil MCP.
|
||||
mcpCallTimeout = 120 * time.Second
|
||||
// mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput).
|
||||
mcpMaxOutput = 12000
|
||||
)
|
||||
|
||||
// mcpSession est une connexion vivante à un serveur MCP.
|
||||
@@ -398,10 +396,12 @@ func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Le résultat complet part au modèle (même règle que la lecture mémoire) :
|
||||
// plus de troncature ici. Un serveur MCP est configuré EXPRÈS par le
|
||||
// propriétaire de la machine pour ce qu'il rend ; le couper au milieu, c'est
|
||||
// rendre la réponse inutilisable. L'UI, elle, n'affiche qu'un aperçu et
|
||||
// charge le reste à la demande (voir fillToolResult et /api/chat/tool-result).
|
||||
out := flattenMCPContent(res)
|
||||
if r := []rune(out); len(r) > mcpMaxOutput {
|
||||
out = string(r[:mcpMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
if res.IsError {
|
||||
return "[l'outil a renvoyé une erreur]\n" + out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package loki
|
||||
|
||||
// mem_crypto.go — primitives de chiffrement de la mémoire.
|
||||
//
|
||||
// Modèle à enveloppe (voir docs/chiffrement-memoire-et-sauvegarde.md) :
|
||||
// - DEK (Data Encryption Key) : 32 octets aléatoires, chiffre chaque page
|
||||
// (AES-256-GCM, nonce aléatoire par écriture). Ne change jamais.
|
||||
// - KEK (Key Encryption Key) : dérivée d'un mot de passe par Argon2id, sert
|
||||
// UNIQUEMENT à (dé)wrapper la DEK (voir mem_vault.go).
|
||||
//
|
||||
// Aucune dépendance exotique : AES-GCM de la stdlib + Argon2id de x/crypto.
|
||||
// La DEK en clair ne touche jamais le disque : elle vit en RAM le temps d'une
|
||||
// session déverrouillée, et sur disque uniquement sous forme wrappée.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
memDEKLen = 32 // AES-256
|
||||
memNonceLen = 12 // taille standard du nonce GCM
|
||||
memSaltLen = 16 // sel Argon2id
|
||||
)
|
||||
|
||||
// Paramètres Argon2id par défaut. Stockés dans chaque wrap du keyvault pour que
|
||||
// de futurs réglages n'invalident pas les coffres existants (on relit toujours
|
||||
// les paramètres écrits, jamais ces constantes, lors du déballage).
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // 64 Mio
|
||||
argonThreads = 1
|
||||
)
|
||||
|
||||
// En-tête d'une page chiffrée. Sert de marqueur de format ET d'AAD (le tag GCM
|
||||
// couvre ainsi la version : impossible de rejouer un blob d'une autre version).
|
||||
var memPageMagic = []byte("LOKIMEMv1")
|
||||
|
||||
// errNotEncrypted signale un contenu qui n'est pas une page chiffrée valide
|
||||
// (mauvais magic) — utile pour distinguer « clair » de « chiffré » à la volée.
|
||||
var errNotEncrypted = errors.New("contenu non chiffré (magic absent)")
|
||||
|
||||
// randBytes renvoie n octets aléatoires cryptographiques.
|
||||
func randBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return nil, fmt.Errorf("source aléatoire indisponible : %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// deriveKEK dérive une clé de 32 octets d'un mot de passe via Argon2id.
|
||||
func deriveKEK(password string, salt []byte, t, m uint32, p uint8) []byte {
|
||||
if t == 0 {
|
||||
t = argonTime
|
||||
}
|
||||
if m == 0 {
|
||||
m = argonMemory
|
||||
}
|
||||
if p == 0 {
|
||||
p = argonThreads
|
||||
}
|
||||
return argon2.IDKey([]byte(password), salt, t, m, p, memDEKLen)
|
||||
}
|
||||
|
||||
// gcmSeal chiffre plaintext avec key (AES-256-GCM) et renvoie nonce||ciphertext.
|
||||
// aad (données authentifiées mais non chiffrées) lie le blob à un contexte.
|
||||
func gcmSeal(key, plaintext, aad []byte) ([]byte, error) {
|
||||
gcm, err := memNewGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce, err := randBytes(memNonceLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct := gcm.Seal(nil, nonce, plaintext, aad)
|
||||
out := make([]byte, 0, len(nonce)+len(ct))
|
||||
out = append(out, nonce...)
|
||||
out = append(out, ct...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// gcmOpen déchiffre un blob nonce||ciphertext produit par gcmSeal. Une clé
|
||||
// erronée ou un blob altéré renvoie une erreur (jamais un contenu partiel).
|
||||
func gcmOpen(key, blob, aad []byte) ([]byte, error) {
|
||||
gcm, err := memNewGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blob) < memNonceLen {
|
||||
return nil, errors.New("blob trop court")
|
||||
}
|
||||
nonce, ct := blob[:memNonceLen], blob[memNonceLen:]
|
||||
pt, err := gcm.Open(nil, nonce, ct, aad)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("déchiffrement refusé (clé erronée ou donnée altérée) : %w", err)
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
func memNewGCM(key []byte) (cipher.AEAD, error) {
|
||||
if len(key) != memDEKLen {
|
||||
return nil, fmt.Errorf("clé de %d octets, %d attendus", len(key), memDEKLen)
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// encPage chiffre le contenu d'une page mémoire : magic || nonce || ciphertext.
|
||||
// Le magic sert d'AAD (authentifié) et de marqueur de format.
|
||||
func encPage(dek, plaintext []byte) ([]byte, error) {
|
||||
sealed, err := gcmSeal(dek, plaintext, memPageMagic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, 0, len(memPageMagic)+len(sealed))
|
||||
out = append(out, memPageMagic...)
|
||||
out = append(out, sealed...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decPage déchiffre une page produite par encPage. Renvoie errNotEncrypted si
|
||||
// le magic manque (donc si on tombe sur une page restée en clair).
|
||||
func decPage(dek, blob []byte) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, memPageMagic) {
|
||||
return nil, errNotEncrypted
|
||||
}
|
||||
return gcmOpen(dek, blob[len(memPageMagic):], memPageMagic)
|
||||
}
|
||||
|
||||
// looksEncrypted indique si un contenu disque est une page chiffrée (magic).
|
||||
func looksEncrypted(blob []byte) bool { return bytes.HasPrefix(blob, memPageMagic) }
|
||||
|
||||
// memCryptoSelfTest vérifie que la chaîne crypto marche RÉELLEMENT sur cette
|
||||
// machine/ce build avant qu'on chiffre la moindre donnée : round-trip DEK sur
|
||||
// une page, plus un cycle KEK wrap/unwrap. Toute anomalie renvoie une erreur, et
|
||||
// l'appelant DOIT alors refuser d'activer le chiffrement (on ne touche pas à la
|
||||
// mémoire tant que ceci n'a pas réussi).
|
||||
func memCryptoSelfTest() error {
|
||||
dek, err := randBytes(memDEKLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sample := []byte("loki auto-test — éàçùô — 0123456789 — ✔")
|
||||
|
||||
enc, err := encPage(dek, sample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test chiffrement page : %w", err)
|
||||
}
|
||||
dec, err := decPage(dek, enc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test déchiffrement page : %w", err)
|
||||
}
|
||||
if !bytes.Equal(dec, sample) {
|
||||
return errors.New("auto-test : round-trip page incohérent")
|
||||
}
|
||||
// Une DEK erronée DOIT échouer (sinon le GCM ne protège rien).
|
||||
badKey, _ := randBytes(memDEKLen)
|
||||
if _, err := decPage(badKey, enc); err == nil {
|
||||
return errors.New("auto-test : une clé erronée a déchiffré (GCM cassé ?!)")
|
||||
}
|
||||
|
||||
// Cycle KEK : wrap la DEK sous un mot de passe, puis déballe.
|
||||
salt, err := randBytes(memSaltLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kek := deriveKEK("mot-de-passe-auto-test", salt, argonTime, argonMemory, argonThreads)
|
||||
box, err := gcmSeal(kek, dek, []byte("loki-vault-selftest"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test wrap DEK : %w", err)
|
||||
}
|
||||
got, err := gcmOpen(kek, box, []byte("loki-vault-selftest"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test unwrap DEK : %w", err)
|
||||
}
|
||||
if !bytes.Equal(got, dek) {
|
||||
return errors.New("auto-test : DEK déballée ≠ DEK d'origine")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemCryptoSelfTest(t *testing.T) {
|
||||
if err := memCryptoSelfTest(); err != nil {
|
||||
t.Fatalf("auto-test crypto échoué : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncDecPageRoundTrip(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
cases := []string{"", "court", "# titre\n\nlignes\néàçù ✔\n", strings.Repeat("x", 100000)}
|
||||
for _, c := range cases {
|
||||
enc, err := encPage(dek, []byte(c))
|
||||
if err != nil {
|
||||
t.Fatalf("encPage: %v", err)
|
||||
}
|
||||
if !looksEncrypted(enc) {
|
||||
t.Fatal("looksEncrypted devrait être vrai sur une page chiffrée")
|
||||
}
|
||||
dec, err := decPage(dek, enc)
|
||||
if err != nil {
|
||||
t.Fatalf("decPage: %v", err)
|
||||
}
|
||||
if !bytes.Equal(dec, []byte(c)) {
|
||||
t.Fatalf("round-trip incohérent pour %q", c[:min(len(c), 20)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPagePlaintextDetected(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
if _, err := decPage(dek, []byte("# page en clair")); err != errNotEncrypted {
|
||||
t.Fatalf("attendu errNotEncrypted, obtenu %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPageWrongKeyFails(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
bad, _ := randBytes(memDEKLen)
|
||||
enc, _ := encPage(dek, []byte("secret"))
|
||||
if _, err := decPage(bad, enc); err == nil {
|
||||
t.Fatal("une clé erronée n'aurait pas dû déchiffrer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPageTamperedFails(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
enc, _ := encPage(dek, []byte("secret"))
|
||||
enc[len(enc)-1] ^= 0xFF // altère le tag
|
||||
if _, err := decPage(dek, enc); err == nil {
|
||||
t.Fatal("un blob altéré n'aurait pas dû déchiffrer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileVerified(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "sub", "f.bin")
|
||||
data := []byte("contenu vérifié éàç")
|
||||
if err := memWriteFileVerified(p, data, 0o600); err != nil {
|
||||
t.Fatalf("écriture vérifiée: %v", err)
|
||||
}
|
||||
back, _ := os.ReadFile(p)
|
||||
if !bytes.Equal(back, data) {
|
||||
t.Fatal("relecture ≠ écrit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package loki
|
||||
|
||||
// mem_fsutil.go — écritures fichier sûres pour la mémoire : temporaire + fsync +
|
||||
// rename atomique. Aucune donnée n'est jamais écrite « en place » par-dessus une
|
||||
// ancienne : on écrit à côté, on force sur disque, puis on bascule d'un rename
|
||||
// (atomique au niveau du système de fichiers).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// memWriteFileAtomic écrit data dans path de façon atomique et durable. En cas
|
||||
// d'échec, path conserve son ancien contenu (le temporaire est nettoyé).
|
||||
func memWriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path)+"-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil { // durabilité : sur disque avant rename
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// memWriteFileVerified écrit data atomiquement PUIS relit le fichier et compare
|
||||
// octet pour octet. C'est la garantie « ce qui est sur le disque est bien ce
|
||||
// qu'on voulait » avant toute suppression de l'original. En cas d'écart, le
|
||||
// fichier écrit est retiré et une erreur est renvoyée.
|
||||
func memWriteFileVerified(path string, data []byte, perm os.FileMode) error {
|
||||
if err := memWriteFileAtomic(path, data, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
back, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relecture de vérification impossible : %w", err)
|
||||
}
|
||||
if len(back) != len(data) {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("vérification échouée : %d octets relus, %d écrits", len(back), len(data))
|
||||
}
|
||||
for i := range back {
|
||||
if back[i] != data[i] {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("vérification échouée : octet %d diffère", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package loki
|
||||
|
||||
// mem_health.go — état de santé de la mémoire pour l'UI : chiffrement actif ?
|
||||
// verrouillé ? toutes les pages déchiffrables ? combien de copies du keyvault ?
|
||||
// dernier snapshot ? C'est le tableau de bord qui rassure d'un coup d'œil.
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// MemHealth résume l'état de la mémoire (sérialisé tel quel vers l'UI).
|
||||
type MemHealth struct {
|
||||
Encrypted bool `json:"encrypted"` // chiffrement activé (drapeau)
|
||||
Fully bool `json:"fully"` // TOUT est réellement chiffré au repos (pages + conversations)
|
||||
Locked bool `json:"locked"` // chiffré mais DEK absente
|
||||
Pages int `json:"pages"` // nb de pages
|
||||
Readable int `json:"readable"` // pages déchiffrables (ou claires)
|
||||
Unreadable int `json:"unreadable"` // pages illisibles (chiffrées + verrouillé, ou corrompues)
|
||||
Mixed bool `json:"mixed"` // mélange clair/chiffré (migration en cours ?)
|
||||
VaultCopies int `json:"vault_copies"` // nb de copies du keyvault présentes (sur 3)
|
||||
HasRecovery bool `json:"has_recovery"` // une clé de récupération est enregistrée
|
||||
Snapshots int `json:"snapshots"` // nb de snapshots locaux
|
||||
LastSnapshot string `json:"last_snapshot"` // ID du snapshot le plus récent
|
||||
MigrationBusy bool `json:"migration_busy"` // une migration est en cours (journal présent)
|
||||
}
|
||||
|
||||
// memHealth calcule l'état courant.
|
||||
func memHealth() MemHealth {
|
||||
h := MemHealth{Encrypted: memEncActive(), Locked: memEncActive() && !memUnlocked()}
|
||||
|
||||
clair, chiffre := 0, 0
|
||||
for _, name := range mdPages() {
|
||||
h.Pages++
|
||||
p, _ := safeMemPath(name)
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
h.Unreadable++
|
||||
continue
|
||||
}
|
||||
if looksEncrypted(raw) {
|
||||
chiffre++
|
||||
if _, err := decodeMemContent(raw); err == nil {
|
||||
h.Readable++
|
||||
} else {
|
||||
h.Unreadable++
|
||||
}
|
||||
} else {
|
||||
clair++
|
||||
h.Readable++
|
||||
}
|
||||
}
|
||||
h.Mixed = clair > 0 && chiffre > 0
|
||||
|
||||
// Fully : la case « activer le chiffrement » ne doit être cochée QUE si TOUT est
|
||||
// réellement chiffré au repos — aucune page en clair ET aucune valeur de
|
||||
// conversation en clair. Vérifiable même verrouillé (looksEncrypted lit le magic
|
||||
// sans la DEK). Un chiffrement partiel/raté laisse donc la case DÉCOCHÉE.
|
||||
if h.Encrypted {
|
||||
full := clair == 0 && h.Pages == chiffre // toutes les pages chiffrées (0 en clair)
|
||||
if full {
|
||||
for _, b := range encryptedBuckets {
|
||||
if !bucketFullyEncrypted(b) {
|
||||
full = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
h.Fully = full
|
||||
}
|
||||
|
||||
// Copies du keyvault présentes.
|
||||
if _, err := os.Stat(vaultPathPrimary()); err == nil {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if _, err := os.Stat(vaultPathBackup()); err == nil {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if len(getBytes(bkState, vaultDBKey)) > 0 {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if v, _ := loadVault(); v != nil {
|
||||
h.HasRecovery = v.hasKind(wrapRecovery)
|
||||
}
|
||||
|
||||
snaps := listSnapshots()
|
||||
h.Snapshots = len(snaps)
|
||||
if len(snaps) > 0 {
|
||||
h.LastSnapshot = snaps[0].ID
|
||||
}
|
||||
_, h.MigrationBusy = readMigrationJournal()
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package loki
|
||||
|
||||
// mem_io.go — couche d'accès transparente aux pages mémoire. C'est le SEUL
|
||||
// endroit qui sait si une page est chiffrée ou non : chat_memory.go passe par
|
||||
// ici et n'a jamais à s'en soucier.
|
||||
//
|
||||
// Choix d'implémentation : le NOM du fichier ne change pas (toujours foo.md).
|
||||
// C'est le CONTENU qui est chiffré en place, précédé d'un magic (voir
|
||||
// mem_crypto.go) qui rend chaque fichier auto-descriptif. Avantage : rien à
|
||||
// changer dans le listing, la recherche ou la validation de chemins — la
|
||||
// détection se fait sur le contenu, pas sur l'extension.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errMemLocked : page chiffrée alors que la mémoire est verrouillée (DEK absente).
|
||||
var errMemLocked = errors.New("page chiffrée, mémoire verrouillée")
|
||||
|
||||
// memEncActive indique si le chiffrement de la mémoire est activé (réglage
|
||||
// MEM_ENCRYPTED). Indépendant de l'état verrouillé/déverrouillé.
|
||||
func memEncActive() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_ENCRYPTED"])) {
|
||||
case "1", "on", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decodeMemContent transforme le contenu BRUT d'un fichier en texte lisible :
|
||||
// - pas chiffré (pas de magic) : renvoyé tel quel.
|
||||
// - chiffré + DEK en RAM : déchiffré.
|
||||
// - chiffré + verrouillé : errMemLocked.
|
||||
func decodeMemContent(raw []byte) ([]byte, error) {
|
||||
if !looksEncrypted(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return decPage(dek, raw)
|
||||
}
|
||||
|
||||
// memReadPage lit une page par son nom et renvoie son texte clair. Erreur si le
|
||||
// fichier est absent, ou chiffré alors que la mémoire est verrouillée.
|
||||
func memReadPage(name string) ([]byte, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeMemContent(raw)
|
||||
}
|
||||
|
||||
// memPageText lit une page et renvoie (texte, lisible). Ne renvoie jamais
|
||||
// d'erreur : une page chiffrée non déchiffrable donne readable=false (utile pour
|
||||
// lister/chercher sans planter quand la mémoire est verrouillée).
|
||||
func memPageText(name string) (string, bool) {
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// encodeMemContent prépare le contenu à écrire sur disque selon l'état du
|
||||
// chiffrement : chiffré si activé ET déverrouillé, clair sinon. Renvoie une
|
||||
// erreur si le chiffrement est activé mais la mémoire verrouillée (on refuse
|
||||
// alors d'écrire, plutôt que d'écrire en clair une donnée censée être chiffrée).
|
||||
func encodeMemContent(plain []byte) ([]byte, error) {
|
||||
if !memEncActive() {
|
||||
return plain, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return encPage(dek, plain)
|
||||
}
|
||||
|
||||
// memEncoderNow résout l'état du chiffrement (drapeau + DEK) AVANT toute
|
||||
// transaction, et renvoie un encodeur PUR. C'est la seule façon de chiffrer une
|
||||
// valeur écrite À L'INTÉRIEUR d'une transaction bbolt : la base n'est pas
|
||||
// rentrante — memEncActive lit la configuration, donc rouvrirait la base et se
|
||||
// bloquerait sur son propre verrou (vécu sur l'archivage des blocs recall).
|
||||
func memEncoderNow() (func([]byte) ([]byte, error), error) {
|
||||
if !memEncActive() {
|
||||
return func(p []byte) ([]byte, error) { return p, nil }, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return func(p []byte) ([]byte, error) { return encPage(dek, p) }, nil
|
||||
}
|
||||
|
||||
// writeMemFile écrit (crée ou remplace) une page de façon sûre : encode selon
|
||||
// l'état du chiffrement, garde un .bak de la version précédente, écrit de façon
|
||||
// atomique et vérifiée. Un échec laisse l'ancienne version intacte.
|
||||
func writeMemFile(name string, plain []byte) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := encodeMemContent(plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// .bak de la version précédente (best-effort) : filet en cas de pépin juste
|
||||
// après le rename.
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
return memWriteFileVerified(p, out, 0o600)
|
||||
}
|
||||
|
||||
// writeMemPlain écrit une page EN CLAIR quel que soit l'état du chiffrement.
|
||||
// Réservé au déchiffrement (DisableMemEncryption) : il faut pouvoir poser du
|
||||
// clair pendant que le drapeau MEM_ENCRYPTED est encore levé, sinon writeMemFile
|
||||
// re-chiffrerait ce qu'on cherche justement à déchiffrer.
|
||||
func writeMemPlain(name string, plain []byte) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
return memWriteFileVerified(p, plain, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package loki
|
||||
|
||||
// mem_migrate.go — bascule chiffrer / déchiffrer la mémoire, sans jamais risquer
|
||||
// de la perdre. Règles :
|
||||
// - auto-test crypto AVANT de toucher quoi que ce soit ;
|
||||
// - snapshot complet AVANT toute bascule ;
|
||||
// - écriture atomique + relecture vérifiée de CHAQUE page ;
|
||||
// - journal de migration pour reprendre proprement après un crash ;
|
||||
// - une page à moitié migrée reste lisible : le contenu est auto-descriptif
|
||||
// (magic), donc pages chiffrées et pages claires coexistent sans danger.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func migrationJournalPath() string { return filepath.Join(memoryDir(), ".migration") }
|
||||
|
||||
type migrationJournal struct {
|
||||
Op string `json:"op"` // "encrypt" | "decrypt"
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func writeMigrationJournal(op string) error {
|
||||
b, _ := json.Marshal(migrationJournal{Op: op, StartedAt: time.Now().UTC()})
|
||||
return memWriteFileAtomic(migrationJournalPath(), b, 0o600)
|
||||
}
|
||||
|
||||
func clearMigrationJournal() { os.Remove(migrationJournalPath()) }
|
||||
|
||||
func readMigrationJournal() (*migrationJournal, bool) {
|
||||
b, err := os.ReadFile(migrationJournalPath())
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var j migrationJournal
|
||||
if json.Unmarshal(b, &j) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &j, true
|
||||
}
|
||||
|
||||
// mdPages liste les noms de fichiers .md du dossier mémoire (les pages) du projet
|
||||
// ACTIF. Utilisé par les affichages ; le chiffrement, lui, passe par
|
||||
// allMemPageFiles pour couvrir TOUS les projets.
|
||||
func mdPages() []string {
|
||||
entries, err := os.ReadDir(memoryDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allMemPageFiles renvoie les chemins ABSOLUS de toutes les pages .md de TOUS les
|
||||
// projets (memory/<slug>/*.md). Sert au chiffrement/déchiffrement global : la DEK
|
||||
// est unique, mais les pages sont réparties par projet.
|
||||
func allMemPageFiles() []string {
|
||||
var out []string
|
||||
roots, err := os.ReadDir(projectsRoot())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(projectsRoot(), d.Name())
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
out = append(out, filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EnableMemEncryption chiffre la mémoire. Génère la DEK, la wrappe sous le mot de
|
||||
// passe ET sous une clé de récupération (renvoyée : à montrer UNE fois à
|
||||
// l'utilisateur), puis chiffre chaque page en vérifiant. Idempotent-safe : si
|
||||
// déjà activé, renvoie une erreur claire.
|
||||
func EnableMemEncryption(password string) (recoveryKey string, err error) {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return "", fmt.Errorf("mot de passe vide")
|
||||
}
|
||||
if memEncActive() {
|
||||
return "", fmt.Errorf("le chiffrement est déjà activé")
|
||||
}
|
||||
// GARDE-FOU ANTI-PERTE : refuser de créer un nouveau coffre si un coffre existe
|
||||
// DÉJÀ sur le disque, même quand le drapeau MEM_ENCRYPTED a disparu. Sinon un
|
||||
// « ré-activer » (drapeau perdu, ex. effacé par un changement de preset) forgeait
|
||||
// une NOUVELLE DEK et re-chiffrait par-dessus : les pages déjà chiffrées sous
|
||||
// l'ancienne DEK devenaient définitivement illisibles. Ici on impose de
|
||||
// DÉVERROUILLER l'existant (unlock) au lieu de re-chiffrer. C'est ce bug qui a
|
||||
// orphelin des pages le 2026-08-25.
|
||||
if vaultExists() {
|
||||
return "", fmt.Errorf("un coffre de chiffrement existe déjà : déverrouille la mémoire (unlock) au lieu de la re-chiffrer")
|
||||
}
|
||||
if err := memCryptoSelfTest(); err != nil {
|
||||
return "", fmt.Errorf("auto-test crypto échoué, chiffrement refusé : %w", err)
|
||||
}
|
||||
if _, err := snapshotMemory("avant-chiffrement"); err != nil {
|
||||
return "", fmt.Errorf("snapshot de sécurité impossible, chiffrement annulé : %w", err)
|
||||
}
|
||||
|
||||
v, dek, err := newVault()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := v.addSecretWrap(dek, wrapPassword, "principal", password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
recoveryKey, err = newRecoveryKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := v.addSecretWrap(dek, wrapRecovery, "récupération", normalizeRecovery(recoveryKey)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := saveVault(v); err != nil {
|
||||
return "", fmt.Errorf("écriture du keyvault : %w", err)
|
||||
}
|
||||
|
||||
// À partir d'ici la DEK est en RAM et le chiffrement est actif : writeMemFile
|
||||
// chiffrera. On journalise, on migre chaque page, on lève le journal.
|
||||
setMemDEK(dek)
|
||||
if err := SetConfigKey("MEM_ENCRYPTED", "1"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeMigrationJournal("encrypt"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := reencryptPlaintextPages(); err != nil {
|
||||
// Journal laissé en place : la reprise au démarrage réessaiera. Les pages
|
||||
// déjà chiffrées sont valides, les pages claires restent lisibles.
|
||||
return recoveryKey, fmt.Errorf("chiffrement partiel (sera repris) : %w", err)
|
||||
}
|
||||
// Étend le chiffrement aux conversations (fil courant + archives + index).
|
||||
if err := reencryptChatStores(); err != nil {
|
||||
return recoveryKey, fmt.Errorf("chiffrement conversations partiel (sera repris) : %w", err)
|
||||
}
|
||||
clearMigrationJournal()
|
||||
scrubPlaintextResidue() // sinon le texte en clair traînerait à côté du chiffré
|
||||
return recoveryKey, nil
|
||||
}
|
||||
|
||||
// scrubPlaintextResidue efface le texte en clair laissé sur le disque APRÈS un
|
||||
// chiffrement réussi et vérifié : les .bak clairs (copies de sauvegarde d'une
|
||||
// écriture) et le snapshot « avant-chiffrement » (copie intégrale en clair).
|
||||
// Sans ça, chiffrer ne servirait à rien — le clair resterait lisible à côté.
|
||||
// Sûr : les pages chiffrées ont été relues et validées une par une, le keyvault
|
||||
// (3 copies + clé de récupération) garantit la récupération.
|
||||
func scrubPlaintextResidue() {
|
||||
// .bak clairs de TOUS les projets (memory/<slug>/*.bak).
|
||||
if roots, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(projectsRoot(), d.Name())
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".bak") {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, e.Name())
|
||||
if b, err := os.ReadFile(p); err == nil && !looksEncrypted(b) {
|
||||
os.Remove(p) // .bak en clair : à retirer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range listSnapshots() {
|
||||
if strings.Contains(s.Reason, "avant-chiffrement") {
|
||||
os.RemoveAll(filepath.Join(snapshotsRoot(), s.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reencryptPlaintextPages (re)chiffre toutes les pages encore en clair, DANS TOUS
|
||||
// LES PROJETS, et vérifie chacune par relecture. Sûr à rejouer.
|
||||
func reencryptPlaintextPages() error {
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DEK indisponible : %w", err)
|
||||
}
|
||||
for _, p := range allMemPageFiles() {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture %s : %w", p, err)
|
||||
}
|
||||
if looksEncrypted(raw) {
|
||||
continue // déjà fait
|
||||
}
|
||||
enc, err := encPage(dek, raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("chiffrement %s : %w", p, err)
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
if err := memWriteFileVerified(p, enc, 0o600); err != nil {
|
||||
return fmt.Errorf("écriture chiffrée %s : %w", p, err)
|
||||
}
|
||||
// Vérifie que la page se relit et se déchiffre à l'identique.
|
||||
back, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relecture %s : %w", p, err)
|
||||
}
|
||||
plain, err := decPage(dek, back)
|
||||
if err != nil || string(plain) != string(raw) {
|
||||
return fmt.Errorf("vérification post-chiffrement de %s échouée", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableMemEncryption remet la mémoire en clair. Exige que la mémoire soit
|
||||
// déverrouillée (DEK en RAM). Snapshot d'abord, puis chaque page est déchiffrée,
|
||||
// écrite en clair et vérifiée ; enfin le keyvault est retiré.
|
||||
func DisableMemEncryption() error {
|
||||
if !memEncActive() {
|
||||
return fmt.Errorf("le chiffrement n'est pas activé")
|
||||
}
|
||||
if !memUnlocked() {
|
||||
return fmt.Errorf("mémoire verrouillée : déverrouille-la avant de déchiffrer")
|
||||
}
|
||||
if _, err := snapshotMemory("avant-dechiffrement"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, déchiffrement annulé : %w", err)
|
||||
}
|
||||
if err := writeMigrationJournal("decrypt"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// On garde le drapeau LEVÉ et la DEK en RAM pendant toute l'opération : on
|
||||
// écrit le clair via writeMemPlain (qui ignore le drapeau), on vérifie CHAQUE
|
||||
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
|
||||
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
|
||||
// levé, donc DisableMemEncryption peut être relancé).
|
||||
if err := decryptAllPages(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decryptChatStores(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
|
||||
// retirer le keyvault et purger la DEK.
|
||||
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
removeVault()
|
||||
clearMemDEK()
|
||||
clearMigrationJournal()
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
|
||||
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
|
||||
func decryptAllPages() error {
|
||||
for _, p := range allMemPageFiles() {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture %s : %w", p, err)
|
||||
}
|
||||
if !looksEncrypted(raw) {
|
||||
continue // déjà en clair
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err)
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
|
||||
return fmt.Errorf("écriture claire %s : %w", p, err)
|
||||
}
|
||||
back, err := os.ReadFile(p)
|
||||
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
|
||||
return fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumeMemMigration reprend une migration interrompue au démarrage. Pour une
|
||||
// migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le
|
||||
// (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un
|
||||
// prochain déverrouillage. Ne perd jamais de données.
|
||||
func resumeMemMigration() {
|
||||
j, ok := readMigrationJournal()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch j.Op {
|
||||
case "encrypt":
|
||||
if memEncActive() && memUnlocked() {
|
||||
if err := reencryptPlaintextPages(); err == nil {
|
||||
_ = reencryptChatStores()
|
||||
clearMigrationJournal()
|
||||
scrubPlaintextResidue()
|
||||
}
|
||||
}
|
||||
case "decrypt":
|
||||
// Déchiffrement interrompu : si la mémoire est déverrouillée et le drapeau
|
||||
// encore levé, on termine proprement (écrit le clair restant), puis on
|
||||
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
|
||||
// et l'écran de santé signalera le mélange.
|
||||
if memEncActive() && memUnlocked() {
|
||||
if err := decryptAllPages(); err == nil {
|
||||
_ = SetConfigKey("MEM_ENCRYPTED", "")
|
||||
removeVault()
|
||||
clearMemDEK()
|
||||
clearMigrationJournal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedPages écrit quelques pages en clair dans un $LOKI_HOME de test.
|
||||
func seedPages(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
pages := map[string]string{
|
||||
"projet.md": "# Projet\n\nUn secret très important à ne jamais perdre.\n",
|
||||
"copine.md": "# Perso\n\nAnniversaire le 3 mars. éàçù ✔\n",
|
||||
"vide.md": "# Vide\n",
|
||||
}
|
||||
for name, content := range pages {
|
||||
if err := MemAdd(name, content); err != nil {
|
||||
t.Fatalf("MemAdd %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// MemAdd maintient désormais l'index MEMORY.md par le code : il existe donc sur
|
||||
// disque comme les autres pages. On l'ajoute à l'ensemble attendu (avec son
|
||||
// contenu réel) pour que les comptes et les lectures le prennent en compte.
|
||||
if idx := MemContent(memIndexFile); idx != "" {
|
||||
pages[memIndexFile] = idx
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
func TestEnableEncryptReadDisable(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
pages := seedPages(t)
|
||||
|
||||
rec, err := EnableMemEncryption("motdepasse-fort")
|
||||
if err != nil {
|
||||
t.Fatalf("EnableMemEncryption: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(rec) == "" {
|
||||
t.Fatal("clé de récupération vide")
|
||||
}
|
||||
if !memEncActive() || !memUnlocked() {
|
||||
t.Fatal("devrait être actif et déverrouillé après activation")
|
||||
}
|
||||
|
||||
// Sur disque, les pages doivent être chiffrées (magic présent).
|
||||
for name := range pages {
|
||||
p, _ := safeMemPath(name)
|
||||
raw, _ := os.ReadFile(p)
|
||||
if !looksEncrypted(raw) {
|
||||
t.Fatalf("page %s pas chiffrée sur disque", name)
|
||||
}
|
||||
}
|
||||
// Mais lisibles via l'API (déverrouillé).
|
||||
for name, want := range pages {
|
||||
if got := MemContent(name); got != want {
|
||||
t.Fatalf("contenu %s après chiffrement: %q != %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Verrouillage : plus rien de lisible, mais on ne perd rien.
|
||||
clearMemDEK()
|
||||
if got := MemContent("projet.md"); got != "" {
|
||||
t.Fatal("une page chiffrée verrouillée ne devrait pas être lisible")
|
||||
}
|
||||
h := memHealth()
|
||||
if !h.Locked || h.Readable != 0 || h.Unreadable != len(pages) {
|
||||
t.Fatalf("santé verrouillée inattendue: %+v", h)
|
||||
}
|
||||
|
||||
// Déverrouillage via mot de passe.
|
||||
v, _ := loadVault()
|
||||
dek, _, err := v.unlockWith("motdepasse-fort")
|
||||
if err != nil {
|
||||
t.Fatalf("unlock: %v", err)
|
||||
}
|
||||
setMemDEK(dek)
|
||||
if got := MemContent("copine.md"); got != pages["copine.md"] {
|
||||
t.Fatal("contenu illisible après déverrouillage")
|
||||
}
|
||||
|
||||
// Déchiffrement complet : retour en clair, keyvault retiré.
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
t.Fatalf("DisableMemEncryption: %v", err)
|
||||
}
|
||||
if memEncActive() {
|
||||
t.Fatal("chiffrement devrait être désactivé")
|
||||
}
|
||||
for name, want := range pages {
|
||||
p, _ := safeMemPath(name)
|
||||
raw, _ := os.ReadFile(p)
|
||||
if looksEncrypted(raw) {
|
||||
t.Fatalf("page %s encore chiffrée après déchiffrement", name)
|
||||
}
|
||||
if got := MemContent(name); got != want {
|
||||
t.Fatalf("contenu %s après déchiffrement altéré", name)
|
||||
}
|
||||
}
|
||||
if vaultExists() {
|
||||
t.Fatal("le keyvault aurait dû être retiré")
|
||||
}
|
||||
}
|
||||
|
||||
// Récupération : après chiffrement, on doit pouvoir rouvrir avec la clé de
|
||||
// récupération même sans le mot de passe.
|
||||
func TestUnlockWithRecoveryAfterEnable(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
rec, err := EnableMemEncryption("pw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clearMemDEK()
|
||||
v, _ := loadVault()
|
||||
if _, _, err := v.unlockWith(normalizeRecovery(rec)); err != nil {
|
||||
t.Fatalf("récupération après activation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sécurité : quand la mémoire est chiffrée ET verrouillée, toute écriture doit
|
||||
// être REFUSÉE (jamais écrire en clair une donnée censée être chiffrée).
|
||||
func TestWriteRefusedWhenLocked(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clearMemDEK() // verrouille
|
||||
if err := MemAdd("nouvelle.md", "contenu"); err == nil {
|
||||
t.Fatal("MemAdd aurait dû être refusé (mémoire verrouillée)")
|
||||
}
|
||||
if err := MemSave("projet.md", "", "écrasé"); err == nil {
|
||||
t.Fatal("MemSave aurait dû être refusé (mémoire verrouillée)")
|
||||
}
|
||||
// Le fichier d'origine ne doit pas avoir été touché en clair.
|
||||
p, _ := safeMemPath("projet.md")
|
||||
raw, _ := os.ReadFile(p)
|
||||
if !looksEncrypted(raw) {
|
||||
t.Fatal("la page d'origine a été altérée alors que la mémoire était verrouillée")
|
||||
}
|
||||
}
|
||||
|
||||
// Après un chiffrement réussi, AUCUN texte en clair ne doit subsister : ni .bak
|
||||
// clair, ni snapshot « avant-chiffrement ».
|
||||
func TestNoPlaintextResidueAfterEncrypt(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(memoryDir())
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".bak") {
|
||||
b, _ := os.ReadFile(memoryDir() + "/" + e.Name())
|
||||
if !looksEncrypted(b) {
|
||||
t.Fatalf(".bak en clair résiduel : %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range listSnapshots() {
|
||||
if strings.Contains(s.Reason, "avant-chiffrement") {
|
||||
t.Fatalf("snapshot en clair avant-chiffrement resté : %s", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reprise d'un chiffrement interrompu : journal présent + une page restée en
|
||||
// clair, mémoire déverrouillée → resume doit finir le chiffrement.
|
||||
func TestResumeInterruptedEncrypt(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simule une interruption : on repose une page en clair et on rearme le journal.
|
||||
if err := writeMemPlain("projet.md", []byte("# Projet\n\nclair résiduel\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeMigrationJournal("encrypt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resumeMemMigration()
|
||||
p, _ := safeMemPath("projet.md")
|
||||
raw, _ := os.ReadFile(p)
|
||||
if !looksEncrypted(raw) {
|
||||
t.Fatal("la reprise n'a pas re-chiffré la page restée en clair")
|
||||
}
|
||||
if _, busy := readMigrationJournal(); busy {
|
||||
t.Fatal("le journal aurait dû être levé après reprise réussie")
|
||||
}
|
||||
}
|
||||
|
||||
// Un round-trip via snapshot : on prend une copie, on casse la mémoire, on
|
||||
// restaure.
|
||||
func TestSnapshotRestore(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
pages := seedPages(t)
|
||||
id, err := snapshotMemory("test")
|
||||
if err != nil || id == "" {
|
||||
t.Fatalf("snapshot: %v (id=%q)", err, id)
|
||||
}
|
||||
// Corrompt une page et en supprime une autre.
|
||||
pApath, _ := safeMemPath("projet.md")
|
||||
os.WriteFile(pApath, []byte("CORROMPU"), 0o600)
|
||||
MemDelete("copine.md")
|
||||
|
||||
if err := restoreSnapshot(id); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
for name, want := range pages {
|
||||
if got := MemContent(name); got != want {
|
||||
t.Fatalf("après restauration %s: %q != %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package loki
|
||||
|
||||
// mem_snapshots.go — filet de sécurité local : des copies horodatées du dossier
|
||||
// memory/, indépendantes du chiffrement et du relais. Un bug qui corromprait la
|
||||
// mémoire courante se rejoue depuis un snapshot antérieur, même pour un
|
||||
// utilisateur non abonné.
|
||||
//
|
||||
// Les snapshots copient TELS QUELS les fichiers de memory/ (clairs ou chiffrés,
|
||||
// keyvault compris) : ce sont des copies fidèles, jamais une transformation.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Nombre de snapshots conservés (rotation). Au-delà, les plus anciens sont
|
||||
// purgés — sauf ceux marqués « pré-bascule », gardés plus longtemps.
|
||||
const memSnapshotsKept = 20
|
||||
|
||||
func snapshotsRoot() string { return filepath.Join(LokiHome(), "backups", "memory") }
|
||||
|
||||
// MemSnapshot décrit une copie horodatée.
|
||||
type MemSnapshot struct {
|
||||
ID string `json:"id"` // nom de dossier (horodaté)
|
||||
Reason string `json:"reason"` // pourquoi elle a été prise
|
||||
When time.Time `json:"when"`
|
||||
Pages int `json:"pages"` // nb de fichiers .md/.md.enc
|
||||
}
|
||||
|
||||
// snapshotMemory prend une copie du dossier memory/. reason est un libellé court
|
||||
// (ex. "auto", "avant-chiffrement"). Renvoie l'ID de la copie. Ne fait rien (ID
|
||||
// vide) si le dossier mémoire est absent ou vide.
|
||||
func snapshotMemory(reason string) (string, error) {
|
||||
entries, err := os.ReadDir(memoryDir())
|
||||
if err != nil || len(entries) == 0 {
|
||||
return "", nil // rien à sauvegarder
|
||||
}
|
||||
id := time.Now().UTC().Format("20060102-150405") + "-" + sanitizeReason(reason)
|
||||
dst := filepath.Join(snapshotsRoot(), id)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue // memory/ est plat ; on ignore un éventuel sous-dossier
|
||||
}
|
||||
src := filepath.Join(memoryDir(), e.Name())
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lecture %s : %w", e.Name(), err)
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(dst, e.Name()), b, 0o600); err != nil {
|
||||
return "", fmt.Errorf("copie %s : %w", e.Name(), err)
|
||||
}
|
||||
}
|
||||
pruneSnapshots()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// listSnapshots renvoie les snapshots existants, du plus récent au plus ancien.
|
||||
func listSnapshots() []MemSnapshot {
|
||||
entries, err := os.ReadDir(snapshotsRoot())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []MemSnapshot
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := e.Name()
|
||||
pages := 0
|
||||
if inner, err := os.ReadDir(filepath.Join(snapshotsRoot(), id)); err == nil {
|
||||
for _, f := range inner {
|
||||
n := strings.ToLower(f.Name())
|
||||
if strings.HasSuffix(n, ".md") || strings.HasSuffix(n, ".md.enc") {
|
||||
pages++
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, MemSnapshot{ID: id, Reason: reasonOf(id), When: whenOf(id), Pages: pages})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// restoreSnapshot remplace le contenu de memory/ par celui d'un snapshot. Pour
|
||||
// ne rien perdre en cas d'erreur, on prend D'ABORD un snapshot de sécurité de
|
||||
// l'état courant, puis on écrit chaque fichier de façon vérifiée avant de retirer
|
||||
// les fichiers surnuméraires.
|
||||
func restoreSnapshot(id string) error {
|
||||
src := filepath.Join(snapshotsRoot(), filepath.Base(id))
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("snapshot introuvable : %w", err)
|
||||
}
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
restored := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(src, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(memoryDir(), e.Name()), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
restored[e.Name()] = true
|
||||
}
|
||||
// Retire de memory/ ce qui n'est pas dans le snapshot (fichiers apparus après).
|
||||
if cur, err := os.ReadDir(memoryDir()); err == nil {
|
||||
for _, f := range cur {
|
||||
if !f.IsDir() && !restored[f.Name()] {
|
||||
os.Remove(filepath.Join(memoryDir(), f.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneSnapshots garde les memSnapshotsKept plus récents, en conservant en plus
|
||||
// tous les snapshots « avant-* » (pris avant une opération sensible).
|
||||
func pruneSnapshots() {
|
||||
all := listSnapshots()
|
||||
if len(all) <= memSnapshotsKept {
|
||||
return
|
||||
}
|
||||
kept := 0
|
||||
for _, s := range all {
|
||||
if strings.Contains(s.Reason, "avant") {
|
||||
continue // on garde les snapshots de sécurité
|
||||
}
|
||||
kept++
|
||||
if kept > memSnapshotsKept {
|
||||
os.RemoveAll(filepath.Join(snapshotsRoot(), s.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Petits utilitaires d'ID --------------------------------------------------
|
||||
|
||||
func sanitizeReason(reason string) string {
|
||||
reason = strings.ToLower(strings.TrimSpace(reason))
|
||||
var b strings.Builder
|
||||
for _, r := range reason {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
} else if r == ' ' || r == '_' {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "snap"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func reasonOf(id string) string {
|
||||
// format : 20060102-150405-<reason>
|
||||
parts := strings.SplitN(id, "-", 3)
|
||||
if len(parts) == 3 {
|
||||
return parts[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whenOf(id string) time.Time {
|
||||
parts := strings.SplitN(id, "-", 3)
|
||||
if len(parts) >= 2 {
|
||||
if t, err := time.Parse("20060102-150405", parts[0]+"-"+parts[1]); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package loki
|
||||
|
||||
// mem_store.go — chiffrement transparent de VALEURS de la base (bbolt), pour
|
||||
// étendre le chiffrement au-delà des pages mémoire : conversations et archives
|
||||
// contiennent aussi des infos sensibles.
|
||||
//
|
||||
// Même enveloppe que les pages (encPage/decPage + magic) : une valeur est
|
||||
// chiffrée quand le chiffrement est actif ET la mémoire déverrouillée, en clair
|
||||
// sinon. La détection se fait sur le magic, donc clair et chiffré cohabitent
|
||||
// (utile pendant une migration ou un état verrouillé).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// errStoreLocked : écriture chiffrée demandée alors que la DEK n'est pas en RAM.
|
||||
var errStoreLocked = errors.New("valeur chiffrée, mémoire verrouillée")
|
||||
|
||||
// putStoreBytes écrit une valeur, chiffrée si le chiffrement est actif ET
|
||||
// déverrouillé. Si actif mais verrouillé, renvoie errStoreLocked SANS écrire
|
||||
// (on ne remplace jamais une valeur chiffrée par du clair).
|
||||
func putStoreBytes(bucket, key string, plain []byte) error {
|
||||
out, err := encodeMemContent(plain)
|
||||
if err != nil {
|
||||
return errStoreLocked
|
||||
}
|
||||
return putBytes(bucket, key, out)
|
||||
}
|
||||
|
||||
// getStoreBytes lit une valeur et la déchiffre au besoin. Renvoie (nil, false)
|
||||
// si absente, ou si chiffrée alors que la mémoire est verrouillée.
|
||||
func getStoreBytes(bucket, key string) ([]byte, bool) {
|
||||
raw := getBytes(bucket, key)
|
||||
if len(raw) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
dec, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return dec, true
|
||||
}
|
||||
|
||||
// storeBytes : getStoreBytes réduit à sa valeur, pour les appelants qui
|
||||
// traitent déjà « absent » et « illisible » de la même façon (un fil vide).
|
||||
func storeBytes(bucket, key string) []byte {
|
||||
b, _ := getStoreBytes(bucket, key)
|
||||
return b
|
||||
}
|
||||
|
||||
// getStoreBytesErr fait la même lecture que getStoreBytes mais SANS avaler
|
||||
// l'erreur d'accès (getBytes, lui, la jette — c'est le piège documenté sur
|
||||
// getBytesErr : « je n'ai pas pu lire » et « il n'y a rien » ne veulent pas dire
|
||||
// la même chose). Une valeur absente reste (nil, nil) — err n'est non-nil QUE
|
||||
// si la base elle-même n'a pas pu être ouverte/lue (verrou bbolt disputé, disque
|
||||
// indisponible…), pas pour une clé qui n'existe simplement pas encore.
|
||||
func getStoreBytesErr(bucket, key string) ([]byte, error) {
|
||||
raw, err := getBytesErr(bucket, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
dec, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
// Chiffré mais verrouillé (ou payload corrompu) : légitime, pas un échec
|
||||
// d'ACCÈS — même repli que getStoreBytes.
|
||||
return nil, nil
|
||||
}
|
||||
return dec, nil
|
||||
}
|
||||
|
||||
// putStoreJSON sérialise puis écrit (chiffré si actif+déverrouillé).
|
||||
func putStoreJSON(bucket, key string, v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return putStoreBytes(bucket, key, b)
|
||||
}
|
||||
|
||||
// getStoreJSON lit puis décode (déchiffre au besoin). false si absent/verrouillé.
|
||||
func getStoreJSON(bucket, key string, dst any) bool {
|
||||
b, ok := getStoreBytes(bucket, key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return json.Unmarshal(b, dst) == nil
|
||||
}
|
||||
|
||||
// reencryptBucket (re)chiffre toutes les valeurs d'un bucket encore en clair,
|
||||
// avec vérification par relecture. Sûr à rejouer. Exige la DEK en RAM.
|
||||
func reencryptBucket(bucket string) error {
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if looksEncrypted(raw) {
|
||||
continue
|
||||
}
|
||||
if err := putStoreBytes(bucket, k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
back, ok := getStoreBytes(bucket, k)
|
||||
if !ok || string(back) != v {
|
||||
return errors.New("vérification post-chiffrement échouée pour " + bucket + "/" + k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketFullyEncrypted indique qu'AUCUNE valeur du bucket n'est en clair (toutes
|
||||
// portent le magic). Lisible sans la DEK. Utilisé pour savoir si le chiffrement
|
||||
// est réellement complet.
|
||||
func bucketFullyEncrypted(bucket string) bool {
|
||||
for _, v := range allKV(bucket) {
|
||||
if v != "" && !looksEncrypted([]byte(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// encryptedBuckets : les buckets dont les valeurs sont chiffrées quand le
|
||||
// chiffrement est actif. Ici : les discussions (bkChat porte l'index, la
|
||||
// discussion active et le journal de chacune), les blocs archivés au compactage
|
||||
// (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et
|
||||
// les trackers (bkTracker, 3ᵉ type de mémoire).
|
||||
//
|
||||
// Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils
|
||||
// portent des réglages, pas des données personnelles — et surtout le coffre
|
||||
// lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une
|
||||
// boucle : plus moyen de déverrouiller quoi que ce soit.
|
||||
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker}
|
||||
|
||||
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
|
||||
func reencryptChatStores() error {
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := reencryptBucket(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
|
||||
func decryptChatStores() error {
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := decryptBucket(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
|
||||
// la DEK en RAM. Sûr à rejouer.
|
||||
func decryptBucket(bucket string) error {
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if !looksEncrypted(raw) {
|
||||
continue
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := putBytes(bucket, k, plain); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La case « chiffré » (health.Fully) ne doit être vraie QUE si TOUT est chiffré.
|
||||
func TestFullyReflectsRealState(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("a.md", "# A\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = putBytes(bkChat, convKey("c1"), []byte(`{"id":"c1","log":[]}`))
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !memHealth().Fully {
|
||||
t.Fatal("après chiffrement complet, Fully devrait être vrai")
|
||||
}
|
||||
// Une page en clair réapparue → Fully faux.
|
||||
p, _ := safeMemPath("a.md")
|
||||
if err := os.WriteFile(p, []byte("# clair"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memHealth().Fully {
|
||||
t.Fatal("une page en clair aurait dû rendre Fully faux")
|
||||
}
|
||||
// Remets la page chiffrée, salis une discussion → Fully faux aussi.
|
||||
if err := writeMemFile("a.md", []byte("# A\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = putBytes(bkChat, convKey("c2"), []byte("journal en clair"))
|
||||
if memHealth().Fully {
|
||||
t.Fatal("une discussion en clair aurait dû rendre Fully faux")
|
||||
}
|
||||
}
|
||||
|
||||
// Les discussions (journal + index) et les blocs archivés doivent être chiffrés
|
||||
// à l'activation, illisibles verrouillé, et intacts après déchiffrement.
|
||||
func TestConversationEncryptionLifecycle(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
|
||||
// Une discussion avec son journal et son entrée d'index.
|
||||
_ = putStoreBytes(bkChat, convKey("c1"), []byte(`{"id":"c1","log":[{"seq":1}],"messages":[]}`))
|
||||
convIndexSave([]convMeta{{ID: "c1", Title: "Client confidentiel", Created: 1, Updated: 2}})
|
||||
// Une page mémoire, pour activer le chiffrement (il chiffre tout).
|
||||
if err := MemAdd("note.md", "# Note\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sur disque, journal ET index doivent porter le magic.
|
||||
if raw := getBytes(bkChat, convKey("c1")); !looksEncrypted(raw) {
|
||||
t.Fatal("journal de discussion non chiffré sur disque")
|
||||
}
|
||||
if raw := getBytes(bkChat, ckIndex); !looksEncrypted(raw) {
|
||||
t.Fatal("index des discussions non chiffré sur disque")
|
||||
}
|
||||
|
||||
// Déverrouillé : lisible.
|
||||
if idx := convIndex(); len(idx) != 1 || idx[0].Title != "Client confidentiel" {
|
||||
t.Fatalf("index illisible alors que déverrouillé: %+v", idx)
|
||||
}
|
||||
|
||||
// Verrouillé : plus rien de lisible, mais rien de perdu.
|
||||
clearMemDEK()
|
||||
if idx := convIndex(); len(idx) != 0 {
|
||||
t.Fatal("les discussions devraient être masquées quand verrouillé")
|
||||
}
|
||||
if b := storeBytes(bkChat, convKey("c1")); len(b) != 0 {
|
||||
t.Fatal("le journal devrait être illisible quand verrouillé")
|
||||
}
|
||||
// putStoreBytes doit REFUSER d'écrire du clair quand verrouillé.
|
||||
if err := putStoreBytes(bkChat, convKey("c1"), []byte("clair")); err != errStoreLocked {
|
||||
t.Fatalf("écriture clair aurait dû être refusée, obtenu %v", err)
|
||||
}
|
||||
if raw := getBytes(bkChat, convKey("c1")); !looksEncrypted(raw) {
|
||||
t.Fatal("le blob chiffré a été écrasé alors que verrouillé")
|
||||
}
|
||||
|
||||
// Re-déverrouille et déchiffre : tout revient en clair et intact.
|
||||
v, _ := loadVault()
|
||||
dek, _, err := v.unlockWith("pw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMemDEK(dek)
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw := getBytes(bkChat, convKey("c1")); looksEncrypted(raw) {
|
||||
t.Fatal("journal encore chiffré après déchiffrement")
|
||||
}
|
||||
if idx := convIndex(); len(idx) != 1 || idx[0].Title != "Client confidentiel" {
|
||||
t.Fatal("index perdu après déchiffrement")
|
||||
}
|
||||
}
|
||||
|
||||
// Un bloc archivé au compactage est du verbatim de conversation : chiffré comme
|
||||
// le reste, et hors de portée tant que la mémoire est verrouillée.
|
||||
func TestRecallBlocksFollowEncryption(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("note.md", "# Note\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := archiveRecallBlock("tool: lecture", "tool", "contenu très bavard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blk, ok := recallGet(id); !ok || blk.Content != "contenu très bavard" {
|
||||
t.Fatal("bloc illisible alors que déverrouillé")
|
||||
}
|
||||
if hits := recallSearch("bavard", 5); len(hits) != 1 {
|
||||
t.Fatalf("la recherche devrait trouver le bloc déverrouillé, obtenu %d", len(hits))
|
||||
}
|
||||
clearMemDEK()
|
||||
if _, ok := recallGet(id); ok {
|
||||
t.Fatal("bloc lisible alors que verrouillé")
|
||||
}
|
||||
if hits := recallSearch("bavard", 5); len(hits) != 0 {
|
||||
t.Fatal("la recherche ne devrait rien trouver quand verrouillé")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package loki
|
||||
|
||||
// mem_vault.go — le keyvault : la DEK de la mémoire, wrappée par une ou
|
||||
// plusieurs KEK (mot de passe, clé de récupération, mot de passe app), et gardée
|
||||
// en RAM le temps d'une session déverrouillée.
|
||||
//
|
||||
// Le keyvault est le POINT DE DÉFAILLANCE UNIQUE : le perdre = perdre la
|
||||
// mémoire. Il est donc écrit en TROIS copies indépendantes, chacune suffisante
|
||||
// seule, et contient plusieurs wraps indépendants de la même DEK. Perdre le mot
|
||||
// de passe n'est pas fatal tant qu'il reste la clé de récupération.
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Types de wrap. Chaque wrap re-chiffre la MÊME DEK sous une KEK différente.
|
||||
const (
|
||||
wrapPassword = "password" // mot de passe mémoire choisi par l'utilisateur
|
||||
wrapRecovery = "recovery" // clé de récupération (secours anti-perte)
|
||||
wrapE2ERoot = "e2e-root" // racine E2E de l'abonné (mot de passe app)
|
||||
)
|
||||
|
||||
// checkPlain est le témoin scellé par la DEK dans le keyvault : le déballer
|
||||
// prouve que la DEK reconstituée est la bonne (détecte un wrap corrompu).
|
||||
var checkPlain = []byte("loki-memoire-ok-v1")
|
||||
|
||||
type kekParams struct {
|
||||
T uint32 `json:"t"`
|
||||
M uint32 `json:"m"`
|
||||
P uint8 `json:"p"`
|
||||
}
|
||||
|
||||
type vaultWrap struct {
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Salt string `json:"salt"` // base64 du sel Argon2id propre à ce wrap
|
||||
KDF kekParams `json:"kdf"`
|
||||
Box string `json:"box"` // base64 de gcmSeal(KEK, DEK, aad)
|
||||
}
|
||||
|
||||
type keyVault struct {
|
||||
V int `json:"v"`
|
||||
Check string `json:"check"` // base64 de gcmSeal(DEK, checkPlain)
|
||||
Wraps []vaultWrap `json:"wraps"`
|
||||
}
|
||||
|
||||
func vaultWrapAAD(kind string) []byte { return []byte("loki-vault/" + kind) }
|
||||
|
||||
// --- Emplacements du keyvault (3 copies indépendantes) -----------------------
|
||||
|
||||
// Le keyvault est GLOBAL (une seule DEK pour la mémoire de TOUS les projets) : il
|
||||
// vit à la racine de LOKI_HOME, pas dans le dossier d'un projet. L'ancien
|
||||
// emplacement (memory/.keyvault) est migré par ensureDefaultProject.
|
||||
func vaultPathPrimary() string { return filepath.Join(LokiHome(), ".keyvault") }
|
||||
func vaultPathBackup() string { return filepath.Join(LokiHome(), ".keyvault.bak") }
|
||||
|
||||
const vaultDBKey = "mem_keyvault"
|
||||
|
||||
// --- État en RAM de la DEK ----------------------------------------------------
|
||||
|
||||
var (
|
||||
memKeyMu sync.RWMutex
|
||||
memDEK []byte // nil = mémoire verrouillée
|
||||
)
|
||||
|
||||
// memUnlocked indique si la DEK est chargée en RAM (mémoire déverrouillée).
|
||||
func memUnlocked() bool {
|
||||
memKeyMu.RLock()
|
||||
defer memKeyMu.RUnlock()
|
||||
return memDEK != nil
|
||||
}
|
||||
|
||||
// setMemDEK charge la DEK en RAM (copie défensive).
|
||||
func setMemDEK(dek []byte) {
|
||||
memKeyMu.Lock()
|
||||
memDEK = append([]byte(nil), dek...)
|
||||
memKeyMu.Unlock()
|
||||
}
|
||||
|
||||
// clearMemDEK purge la DEK de la RAM (verrouille la mémoire).
|
||||
func clearMemDEK() {
|
||||
memKeyMu.Lock()
|
||||
for i := range memDEK {
|
||||
memDEK[i] = 0
|
||||
}
|
||||
memDEK = nil
|
||||
memKeyMu.Unlock()
|
||||
}
|
||||
|
||||
// currentDEK renvoie la DEK en RAM, ou une erreur si la mémoire est verrouillée.
|
||||
func currentDEK() ([]byte, error) {
|
||||
memKeyMu.RLock()
|
||||
defer memKeyMu.RUnlock()
|
||||
if memDEK == nil {
|
||||
return nil, errors.New("mémoire verrouillée — aucune clé chargée")
|
||||
}
|
||||
return append([]byte(nil), memDEK...), nil
|
||||
}
|
||||
|
||||
// --- Construction et manipulation du keyvault --------------------------------
|
||||
|
||||
// newVault crée un keyvault autour d'une DEK fraîche : aucun wrap encore, mais
|
||||
// le témoin est posé. Renvoie aussi la DEK en clair (à garder en RAM, jamais sur
|
||||
// disque).
|
||||
func newVault() (*keyVault, []byte, error) {
|
||||
dek, err := randBytes(memDEKLen)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
check, err := gcmSeal(dek, checkPlain, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &keyVault{V: 1, Check: base64.StdEncoding.EncodeToString(check)}, dek, nil
|
||||
}
|
||||
|
||||
// addSecretWrap (ou remplace) un wrap de la DEK sous un secret (mot de passe ou
|
||||
// clé de récupération). kind identifie le wrap ; un même kind+label écrase le
|
||||
// précédent (rotation de mot de passe = re-wrap, sans toucher aux pages).
|
||||
func (v *keyVault) addSecretWrap(dek []byte, kind, label, secret string) error {
|
||||
salt, err := randBytes(memSaltLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kp := kekParams{T: argonTime, M: argonMemory, P: argonThreads}
|
||||
kek := deriveKEK(secret, salt, kp.T, kp.M, kp.P)
|
||||
box, err := gcmSeal(kek, dek, vaultWrapAAD(kind))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := vaultWrap{
|
||||
Kind: kind,
|
||||
Label: label,
|
||||
Salt: base64.StdEncoding.EncodeToString(salt),
|
||||
KDF: kp,
|
||||
Box: base64.StdEncoding.EncodeToString(box),
|
||||
}
|
||||
for i := range v.Wraps {
|
||||
if v.Wraps[i].Kind == kind && v.Wraps[i].Label == label {
|
||||
v.Wraps[i] = w
|
||||
return nil
|
||||
}
|
||||
}
|
||||
v.Wraps = append(v.Wraps, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeWrap retire les wraps d'un kind (révocation). Renvoie le nombre retiré.
|
||||
// Refuse de vider le dernier wrap (sinon la DEK deviendrait irrécupérable).
|
||||
func (v *keyVault) removeWrap(kind, label string) (int, error) {
|
||||
kept := v.Wraps[:0:0]
|
||||
removed := 0
|
||||
for _, w := range v.Wraps {
|
||||
if w.Kind == kind && (label == "" || w.Label == label) {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, w)
|
||||
}
|
||||
if removed > 0 && len(kept) == 0 {
|
||||
return 0, errors.New("refus : ce serait le dernier accès à la mémoire")
|
||||
}
|
||||
v.Wraps = kept
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// unlockWith tente de reconstituer la DEK à partir d'un secret : essaie chaque
|
||||
// wrap, et ne renvoie la DEK que si le témoin la valide. Renvoie le kind du wrap
|
||||
// qui a marché (utile pour l'UI).
|
||||
func (v *keyVault) unlockWith(secret string) ([]byte, string, error) {
|
||||
if len(v.Wraps) == 0 {
|
||||
return nil, "", errors.New("keyvault sans aucun wrap")
|
||||
}
|
||||
for _, w := range v.Wraps {
|
||||
salt, err := base64.StdEncoding.DecodeString(w.Salt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
box, err := base64.StdEncoding.DecodeString(w.Box)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
kek := deriveKEK(secret, salt, w.KDF.T, w.KDF.M, w.KDF.P)
|
||||
dek, err := gcmOpen(kek, box, vaultWrapAAD(w.Kind))
|
||||
if err != nil {
|
||||
continue // mauvais secret pour ce wrap, on essaie le suivant
|
||||
}
|
||||
if err := v.validate(dek); err != nil {
|
||||
continue
|
||||
}
|
||||
return dek, w.Kind, nil
|
||||
}
|
||||
return nil, "", errors.New("secret incorrect (aucun wrap ne correspond)")
|
||||
}
|
||||
|
||||
// validate confirme qu'une DEK candidate ouvre bien le témoin du keyvault.
|
||||
func (v *keyVault) validate(dek []byte) error {
|
||||
check, err := base64.StdEncoding.DecodeString(v.Check)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
got, err := gcmOpen(dek, check, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(got, checkPlain) != 1 {
|
||||
return errors.New("témoin du keyvault invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *keyVault) hasKind(kind string) bool {
|
||||
for _, w := range v.Wraps {
|
||||
if w.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Persistance redondée -----------------------------------------------------
|
||||
|
||||
// saveVault écrit le keyvault dans ses TROIS copies. Réussit si AU MOINS une
|
||||
// copie a pu être écrite (on ne veut jamais bloquer sur une copie inaccessible),
|
||||
// mais renvoie l'erreur agrégée sinon.
|
||||
func saveVault(v *keyVault) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []string
|
||||
okAny := false
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err == nil {
|
||||
if err := memWriteFileAtomic(vaultPathPrimary(), data, 0o600); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "primaire: "+err.Error())
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, "memoryDir: "+err.Error())
|
||||
}
|
||||
if err := memWriteFileAtomic(vaultPathBackup(), data, 0o600); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "backup: "+err.Error())
|
||||
}
|
||||
if err := putBytes(bkState, vaultDBKey, data); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "db: "+err.Error())
|
||||
}
|
||||
if !okAny {
|
||||
return fmt.Errorf("aucune copie du keyvault écrite : %s", strings.Join(errs, " ; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadVault lit le keyvault depuis la première copie valide (primaire, backup,
|
||||
// db), puis RÉPARE silencieusement les copies manquantes/corrompues à partir de
|
||||
// celle qui a marché. Renvoie (nil, nil) si aucun keyvault n'existe encore.
|
||||
func loadVault() (*keyVault, error) {
|
||||
sources := []func() ([]byte, error){
|
||||
func() ([]byte, error) { return os.ReadFile(vaultPathPrimary()) },
|
||||
func() ([]byte, error) { return os.ReadFile(vaultPathBackup()) },
|
||||
func() ([]byte, error) {
|
||||
b := getBytes(bkState, vaultDBKey)
|
||||
if len(b) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return b, nil
|
||||
},
|
||||
}
|
||||
var found *keyVault
|
||||
anyPresent := false
|
||||
healthy := 0 // nb de copies présentes ET valides
|
||||
for _, src := range sources {
|
||||
b, err := src()
|
||||
if err != nil || len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
anyPresent = true
|
||||
var v keyVault
|
||||
if json.Unmarshal(b, &v) == nil && v.V > 0 && len(v.Wraps) > 0 {
|
||||
healthy++
|
||||
if found == nil {
|
||||
vv := v
|
||||
found = &vv
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
if anyPresent {
|
||||
return nil, errors.New("keyvault présent mais illisible dans toutes les copies")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
// Ré-harmonise UNIQUEMENT si une des trois copies manque ou est corrompue :
|
||||
// sinon loadVault (appelé à chaque sondage de santé) réécrirait le disque en
|
||||
// boucle pour rien.
|
||||
if healthy < 3 {
|
||||
_ = saveVault(found)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// vaultExists indique si un keyvault est présent (mémoire chiffrée configurée).
|
||||
func vaultExists() bool {
|
||||
v, _ := loadVault()
|
||||
return v != nil
|
||||
}
|
||||
|
||||
// stripServerOpenableWraps retire du coffre tout wrap ouvrable par un secret que
|
||||
// le SERVEUR détient (la clé de pilotage web, jadis ajoutée comme wrap
|
||||
// « clé d'accès »). Objectif : le serveur ne doit JAMAIS pouvoir déchiffrer seul.
|
||||
// La clé de chiffrement ne vit que côté client (localStorage). Idempotent ;
|
||||
// refuse de vider le coffre (removeWrap garde toujours au moins un wrap).
|
||||
func stripServerOpenableWraps() {
|
||||
v, _ := loadVault()
|
||||
if v == nil || !v.hasKind(wrapE2ERoot) {
|
||||
return
|
||||
}
|
||||
if n, err := v.removeWrap(wrapE2ERoot, ""); err == nil && n > 0 {
|
||||
_ = saveVault(v)
|
||||
}
|
||||
}
|
||||
|
||||
// removeVault efface les trois copies du keyvault (après un déchiffrement complet
|
||||
// où il n'a plus lieu d'être).
|
||||
func removeVault() {
|
||||
os.Remove(vaultPathPrimary())
|
||||
os.Remove(vaultPathBackup())
|
||||
putBytes(bkState, vaultDBKey, nil)
|
||||
}
|
||||
|
||||
// --- Clé de récupération ------------------------------------------------------
|
||||
|
||||
// recoveryEnc : base32 Crockford (32 caractères, sans les ambigus I/L/O/U),
|
||||
// pour une saisie humaine sans confusion.
|
||||
var recoveryEnc = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
|
||||
|
||||
// newRecoveryKey génère une clé de récupération lisible : 20 octets aléatoires,
|
||||
// encodés en groupes de 4 (ex. ABCD-EFGH-JKMN-...). C'est un SECRET fort, à
|
||||
// noter hors ligne ; il rouvre la mémoire même si le mot de passe est perdu.
|
||||
func newRecoveryKey() (string, error) {
|
||||
raw, err := randBytes(20)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := recoveryEnc.EncodeToString(raw)
|
||||
var b strings.Builder
|
||||
for i, r := range s {
|
||||
if i > 0 && i%4 == 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// normalizeRecovery met une clé de récupération saisie sous forme canonique
|
||||
// (majuscules, sans séparateurs) pour la dérivation de KEK.
|
||||
func normalizeRecovery(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
s = strings.NewReplacer("-", "", " ", "", "\t", "").Replace(s)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVaultCreateUnlock(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, err := newVault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.addSecretWrap(dek, wrapPassword, "principal", "hunter2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, kind, err := v.unlockWith("hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("unlock: %v", err)
|
||||
}
|
||||
if kind != wrapPassword {
|
||||
t.Fatalf("kind attendu password, obtenu %s", kind)
|
||||
}
|
||||
if !bytes.Equal(got, dek) {
|
||||
t.Fatal("DEK déballée ≠ DEK d'origine")
|
||||
}
|
||||
if _, _, err := v.unlockWith("mauvais"); err == nil {
|
||||
t.Fatal("un mauvais mot de passe n'aurait pas dû ouvrir")
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation : re-wrapper sous un nouveau mot de passe ne doit PAS changer la DEK.
|
||||
func TestVaultRotation(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "ancien")
|
||||
if err := v.addSecretWrap(dek, wrapPassword, "principal", "nouveau"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := v.unlockWith("ancien"); err == nil {
|
||||
t.Fatal("l'ancien mot de passe aurait dû être remplacé")
|
||||
}
|
||||
got, _, err := v.unlockWith("nouveau")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, dek) {
|
||||
t.Fatal("la rotation a changé la DEK")
|
||||
}
|
||||
}
|
||||
|
||||
// La clé de récupération rouvre la mémoire même si le wrap mot de passe est retiré.
|
||||
func TestVaultRecovery(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "motdepasse")
|
||||
rec, err := newRecoveryKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.addSecretWrap(dek, wrapRecovery, "récupération", normalizeRecovery(rec))
|
||||
|
||||
// On détruit l'accès mot de passe.
|
||||
if _, err := v.removeWrap(wrapPassword, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, kind, err := v.unlockWith(normalizeRecovery(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("récupération: %v", err)
|
||||
}
|
||||
if kind != wrapRecovery || !bytes.Equal(got, dek) {
|
||||
t.Fatal("la clé de récupération n'a pas rouvert la mémoire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultRefusesLastWrapRemoval(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "x")
|
||||
if _, err := v.removeWrap(wrapPassword, ""); err == nil {
|
||||
t.Fatal("retirer le dernier wrap aurait dû être refusé")
|
||||
}
|
||||
}
|
||||
|
||||
// Persistance : sauver puis relire depuis les 3 copies, y compris après
|
||||
// destruction d'une ou deux copies.
|
||||
func TestVaultPersistRedundancy(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "pw")
|
||||
if err := saveVault(v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Détruit la copie primaire et la db, il reste le backup.
|
||||
os.Remove(vaultPathPrimary())
|
||||
putBytes(bkState, vaultDBKey, nil)
|
||||
got, err := loadVault()
|
||||
if err != nil {
|
||||
t.Fatalf("load après perte de 2 copies: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("keyvault introuvable alors que le backup existe")
|
||||
}
|
||||
// loadVault doit avoir réparé les copies manquantes.
|
||||
if _, err := os.Stat(vaultPathPrimary()); err != nil {
|
||||
t.Fatal("la copie primaire n'a pas été réparée")
|
||||
}
|
||||
d, _, err := got.unlockWith("pw")
|
||||
if err != nil || !bytes.Equal(d, dek) {
|
||||
t.Fatal("keyvault relu inutilisable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemDEKState(t *testing.T) {
|
||||
if memUnlocked() {
|
||||
clearMemDEK()
|
||||
}
|
||||
if _, err := currentDEK(); err == nil {
|
||||
t.Fatal("currentDEK devrait échouer quand verrouillé")
|
||||
}
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
setMemDEK(dek)
|
||||
if !memUnlocked() {
|
||||
t.Fatal("devrait être déverrouillé")
|
||||
}
|
||||
got, err := currentDEK()
|
||||
if err != nil || !bytes.Equal(got, dek) {
|
||||
t.Fatal("currentDEK incohérent")
|
||||
}
|
||||
clearMemDEK()
|
||||
if memUnlocked() {
|
||||
t.Fatal("devrait être verrouillé après clear")
|
||||
}
|
||||
}
|
||||
|
||||
// Zéro-connaissance : le secret détenu par le SERVEUR (clé de pilotage web_key)
|
||||
// ne doit PAS pouvoir ouvrir le coffre, et stripServerOpenableWraps retire tout
|
||||
// wrap qui le permettrait.
|
||||
func TestServerCannotOpenVault(t *testing.T) {
|
||||
testHome(t)
|
||||
putStr(bkState, "web_key", "cle-de-pilotage-serveur")
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "mon-mdp-client")
|
||||
// La clé de pilotage du serveur n'ouvre pas le coffre.
|
||||
if _, _, err := v.unlockWith("cle-de-pilotage-serveur"); err == nil {
|
||||
t.Fatal("la clé serveur n'aurait jamais dû ouvrir le coffre")
|
||||
}
|
||||
// Si un wrap « clé d'accès » (ouvrable par le serveur) traînait, on le retire.
|
||||
v.addSecretWrap(dek, wrapE2ERoot, "clé d'accès", "cle-de-pilotage-serveur")
|
||||
saveVault(v)
|
||||
if got, _, err := v.unlockWith("cle-de-pilotage-serveur"); err != nil || !bytes.Equal(got, dek) {
|
||||
t.Fatal("pré-condition: le wrap clé d'accès devrait ouvrir avant strip")
|
||||
}
|
||||
stripServerOpenableWraps()
|
||||
v2, _ := loadVault()
|
||||
if _, _, err := v2.unlockWith("cle-de-pilotage-serveur"); err == nil {
|
||||
t.Fatal("après strip, la clé serveur ne doit plus ouvrir le coffre")
|
||||
}
|
||||
if _, _, err := v2.unlockWith("mon-mdp-client"); err != nil {
|
||||
t.Fatal("le mot de passe client doit toujours ouvrir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryKeyFormat(t *testing.T) {
|
||||
rec, err := newRecoveryKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalizeRecovery(rec) == "" {
|
||||
t.Fatal("clé de récupération vide")
|
||||
}
|
||||
if normalizeRecovery("abcd-efgh") != "ABCDEFGH" {
|
||||
t.Fatalf("normalisation inattendue: %q", normalizeRecovery("abcd-efgh"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// push.go — notifications Web Push : quand un tour utilisateur se termine
|
||||
// (chat_conversation.go, turn_done), le serveur Loki pousse une notification
|
||||
// vers les navigateurs abonnés (iPhone verrouillé, Android, desktop) via leur
|
||||
// service de push (Apple/Google). C'est le SERVEUR qui pousse, directement vers
|
||||
// le endpoint du navigateur — ça marche donc app fermée, contrairement à une
|
||||
// notification purement client (l'onglet caché relâche son flux SSE).
|
||||
//
|
||||
// Ce que ça ne fait PAS transiter par le tunnel E2E : la charge utile est
|
||||
// chiffrée de bout en bout avec les clés PROPRES de l'abonnement (RFC 8291),
|
||||
// puis remise au service de push public. L'inscription, elle, passe par /api
|
||||
// (donc jfetch/E2E) comme le reste.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
// fmtDurFR : durée → « 42s », « 3 mn 05s », « 1 h 12 mn » pour le corps de la
|
||||
// notification. Miroir serveur de fmtElapsed() côté UI (09-stream.js).
|
||||
func fmtDurFR(d time.Duration) string {
|
||||
secs := int(d.Round(time.Second).Seconds())
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
h, m, s := secs/3600, (secs%3600)/60, secs%60
|
||||
switch {
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d h %02d mn", h, m)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%d mn %02ds", m, s)
|
||||
default:
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Stockées dans bkState (une seule base bbolt, voir store.go).
|
||||
const (
|
||||
stPushVAPIDPriv = "push_vapid_priv" // clé privée VAPID (base64url)
|
||||
stPushVAPIDPub = "push_vapid_pub" // clé publique VAPID (base64url), servie à l'UI
|
||||
stPushSubs = "push_subs" // liste JSON des abonnements
|
||||
)
|
||||
|
||||
// pushSub : un abonnement PushSubscription du navigateur. Même forme que l'objet
|
||||
// JS, on le reçoit tel quel depuis l'UI et on le rejoue tel quel à l'envoi.
|
||||
type pushSub struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys webpush.Keys `json:"keys"`
|
||||
}
|
||||
|
||||
// vapidMu sérialise la génération paresseuse des clés : deux requêtes /api/push/key
|
||||
// concurrentes sur une base neuve ne doivent pas produire deux paires différentes
|
||||
// (la seconde écraserait la première, invalidant les abonnements de la première).
|
||||
var vapidMu sync.Mutex
|
||||
|
||||
// vapidKeys renvoie la paire VAPID, la générant et la persistant au premier appel.
|
||||
// La paire est STABLE pour la vie de l'installation : la changer invaliderait tous
|
||||
// les abonnements déjà pris (le navigateur signe l'abonnement avec la clé publique).
|
||||
func vapidKeys() (priv, pub string, err error) {
|
||||
vapidMu.Lock()
|
||||
defer vapidMu.Unlock()
|
||||
priv = getStr(bkState, stPushVAPIDPriv)
|
||||
pub = getStr(bkState, stPushVAPIDPub)
|
||||
if priv != "" && pub != "" {
|
||||
return priv, pub, nil
|
||||
}
|
||||
priv, pub, err = webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = putStr(bkState, stPushVAPIDPriv, priv); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = putStr(bkState, stPushVAPIDPub, pub); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return priv, pub, nil
|
||||
}
|
||||
|
||||
// subsMu sérialise les mutations de la liste d'abonnements (lecture-modif-écriture) :
|
||||
// une inscription et une purge concurrentes ne doivent pas s'écraser l'une l'autre.
|
||||
var subsMu sync.Mutex
|
||||
|
||||
func loadSubs() []pushSub {
|
||||
var subs []pushSub
|
||||
getJSON(bkState, stPushSubs, &subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
func saveSubs(subs []pushSub) error { return putJSON(bkState, stPushSubs, subs) }
|
||||
|
||||
// addSub enregistre un abonnement (idempotent : ré-inscrire le même endpoint met
|
||||
// simplement à jour ses clés au lieu de le dupliquer — un navigateur ré-abonné
|
||||
// garde le même endpoint mais peut renouveler ses clés).
|
||||
func addSub(s pushSub) error {
|
||||
if s.Endpoint == "" {
|
||||
return nil
|
||||
}
|
||||
subsMu.Lock()
|
||||
defer subsMu.Unlock()
|
||||
subs := loadSubs()
|
||||
for i, x := range subs {
|
||||
if x.Endpoint == s.Endpoint {
|
||||
subs[i] = s
|
||||
return saveSubs(subs)
|
||||
}
|
||||
}
|
||||
subs = append(subs, s)
|
||||
return saveSubs(subs)
|
||||
}
|
||||
|
||||
// removeSub retire un abonnement par son endpoint (désinscription explicite, ou
|
||||
// purge après un 404/410 « gone » renvoyé par le service de push).
|
||||
func removeSub(endpoint string) {
|
||||
if endpoint == "" {
|
||||
return
|
||||
}
|
||||
subsMu.Lock()
|
||||
defer subsMu.Unlock()
|
||||
subs := loadSubs()
|
||||
out := subs[:0]
|
||||
for _, x := range subs {
|
||||
if x.Endpoint != endpoint {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
_ = saveSubs(out)
|
||||
}
|
||||
|
||||
// pushPayload : ce que le service worker reçoit dans l'événement `push`.
|
||||
type pushPayload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// hasPushSubs : y a-t-il au moins un abonnement ? Évite de sérialiser une charge
|
||||
// utile pour rien à chaque fin de tour quand personne n'a activé les notifs.
|
||||
func hasPushSubs() bool { return len(loadSubs()) > 0 }
|
||||
|
||||
// sendPushToAll pousse une notification à tous les abonnés. Best-effort : les
|
||||
// échecs réseau sont ignorés (le service de push réessaiera selon le TTL), mais un
|
||||
// abonnement rejeté définitivement (404/410) est purgé pour ne pas s'accumuler.
|
||||
// Appelé depuis la goroutine de génération, à turn_done — jamais bloquant pour l'UI.
|
||||
func sendPushToAll(title, body string) {
|
||||
subs := loadSubs()
|
||||
if len(subs) == 0 {
|
||||
fmt.Printf("[push] fin de tour : aucun abonné enregistré (rien à envoyer)\n")
|
||||
return
|
||||
}
|
||||
priv, pub, err := vapidKeys()
|
||||
if err != nil {
|
||||
fmt.Printf("[push] clés VAPID indisponibles : %v\n", err)
|
||||
return
|
||||
}
|
||||
msg, _ := json.Marshal(pushPayload{Title: title, Body: body, Tag: "loki-turn"})
|
||||
opts := &webpush.Options{
|
||||
// ⚠️ SANS préfixe « mailto: » : webpush-go l'ajoute lui-même (sauf si la
|
||||
// chaîne commence par « https: »). Passer « mailto:… » ici donnait
|
||||
// « mailto:mailto:… », un sujet VAPID malformé → Apple répond 403
|
||||
// BadJwtToken et rien n'arrive. On donne donc l'email nu.
|
||||
Subscriber: "mail@nathaninline.com",
|
||||
VAPIDPublicKey: pub,
|
||||
VAPIDPrivateKey: priv,
|
||||
TTL: 120, // périmé après 2 min : une notif « réponse prête » n'a pas de sens tardive
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
}
|
||||
fmt.Printf("[push] envoi à %d abonné(s)…\n", len(subs))
|
||||
for _, s := range subs {
|
||||
sub := &webpush.Subscription{Endpoint: s.Endpoint, Keys: s.Keys}
|
||||
resp, err := webpush.SendNotification(msg, sub, opts)
|
||||
if err != nil {
|
||||
fmt.Printf("[push] échec envoi (%s) : %v\n", endpointHost(s.Endpoint), err)
|
||||
continue
|
||||
}
|
||||
// 201 Created = accepté par le service de push. 4xx = problème (VAPID,
|
||||
// chiffrement, abonnement mort) — on TRACE le corps de la réponse, qui porte
|
||||
// le motif exact d'Apple/Google (ex. « BadJwtToken »).
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
fmt.Printf("[push] refus %d (%s) : %s\n", resp.StatusCode, endpointHost(s.Endpoint), strings.TrimSpace(string(body)))
|
||||
} else {
|
||||
fmt.Printf("[push] accepté %d (%s)\n", resp.StatusCode, endpointHost(s.Endpoint))
|
||||
}
|
||||
// 404/410 = abonnement expiré côté service de push : on le retire.
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
||||
removeSub(s.Endpoint)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// endpointHost : hôte de l'endpoint pour les logs (miroir de pushEndpointHost,
|
||||
// gardé dans ce fichier pour éviter un couplage de compilation entre les deux).
|
||||
func endpointHost(endpoint string) string { return pushEndpointHost(endpoint) }
|
||||
@@ -84,6 +84,8 @@ func Main() {
|
||||
mustExit(cmdOAI(args))
|
||||
case "agent":
|
||||
mustExit(cmdAgent(args))
|
||||
case "computer":
|
||||
mustExit(cmdComputer(args))
|
||||
case "internet":
|
||||
mustExit(cmdInternet(args))
|
||||
case "memory":
|
||||
@@ -156,6 +158,7 @@ Interaction:
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA
|
||||
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
|
||||
accès web de l'IA (moteur intégré ou serveur Crawl4AI)
|
||||
computer [on|off|status] laisse l'IA piloter un navigateur (browser_open/click/type…)
|
||||
|
||||
Accès distant (ajean.link) :
|
||||
link <token> enregistre le jeton et ouvre le tunnel
|
||||
@@ -240,6 +243,12 @@ func memoryDir() string { return projectMemoryDir(activeProjectSlug()) }
|
||||
func modelsDir() string { return filepath.Join(LokiHome(), "models") }
|
||||
func workspaceDir() string { return filepath.Join(LokiHome(), "workspace") }
|
||||
|
||||
// scriptsDir est le dossier des scripts de l'agent : à côté de memory/presets,
|
||||
// HORS du workspace. Le workspace est un bac à sable jetable (clones, tests,
|
||||
// fichiers d'une discussion) qu'un nettoyage — ou la suppression de la
|
||||
// discussion — peut raser ; les scripts qu'on veut CONSERVER vivent ici.
|
||||
func scriptsDir() string { return filepath.Join(LokiHome(), "scripts") }
|
||||
|
||||
// serviceName est le nom de l'unité qui exécute llama-server. Son pendant est
|
||||
// uiUnitName (« loki-ui »), qui sert l'interface et le tunnel.
|
||||
func serviceName() string {
|
||||
|
||||
@@ -22,6 +22,7 @@ type lokiPaths struct {
|
||||
Installed string `json:"installed"` // binaire installé (peut différer de Exe)
|
||||
Workspace string `json:"workspace"` // dossier de travail du mode agent
|
||||
Memory string `json:"memory"`
|
||||
Scripts string `json:"scripts"` // scripts durables de l'agent (hors workspace jetable)
|
||||
Presets string `json:"presets"`
|
||||
Models string `json:"models"`
|
||||
Backends string `json:"backends"`
|
||||
@@ -50,6 +51,7 @@ func currentPaths() lokiPaths {
|
||||
Installed: installedExePath(),
|
||||
Workspace: agentWorkspace(),
|
||||
Memory: memoryDir(),
|
||||
Scripts: scriptsDir(),
|
||||
Presets: presetsDir(),
|
||||
Models: modelsDir(),
|
||||
Backends: backendsDir(),
|
||||
@@ -66,6 +68,7 @@ func cmdWhere(args []string) error {
|
||||
{"binaire installé", p.Installed},
|
||||
{"travail de l'agent", p.Workspace},
|
||||
{"mémoire", p.Memory},
|
||||
{"scripts", p.Scripts},
|
||||
{"presets", p.Presets},
|
||||
{"modèles", p.Models},
|
||||
{"backends", p.Backends},
|
||||
|
||||
+23
-2
@@ -31,6 +31,12 @@ type Task struct {
|
||||
// chantier — une veille « NAS » n'écrit pas dans la mémoire « recettes ». Vide
|
||||
// sur les tâches d'avant les projets : traitées comme « Générale ».
|
||||
Project string `json:"project,omitempty"`
|
||||
// Kind distingue une tâche IA d'une tâche script. Vide ou "agent" = Prompt est
|
||||
// une consigne exécutée par le modèle (RunAutonomous). "script" = on lance
|
||||
// directement le script Script du dossier protégé, SANS charger le modèle ni
|
||||
// consommer de tokens. Le zéro JSON ("") garde le comportement historique (IA).
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Script string `json:"script,omitempty"` // pour Kind=="script" : nom du script dans scriptsDir()
|
||||
// Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON
|
||||
// (tâches créées avant ces champs) garde le comportement historique : accès.
|
||||
NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche
|
||||
@@ -75,9 +81,11 @@ func listTasks() []Task {
|
||||
}
|
||||
|
||||
// taskProjectOf renvoie le projet d'une tâche, l'absence valant projet par défaut :
|
||||
// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire.
|
||||
// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire. Un
|
||||
// projet SUPPRIMÉ depuis retombe de même sur « Générale » : sans ça, la tâche
|
||||
// restait rattachée à un slug fantôme et n'apparaissait plus dans aucun projet.
|
||||
func taskProjectOf(t Task) string {
|
||||
if s := strings.TrimSpace(t.Project); s != "" {
|
||||
if s := strings.TrimSpace(t.Project); s != "" && projectExists(s) {
|
||||
return s
|
||||
}
|
||||
return defaultProjectSlug
|
||||
@@ -101,6 +109,19 @@ func tagOrphanTasks(slug string) {
|
||||
}
|
||||
}
|
||||
|
||||
// listTasksForProject ne renvoie que les tâches rattachées au projet `slug`
|
||||
// (mêmes tri et résolution que listTasks). Sert à cloisonner les outils task_*
|
||||
// exposés à l'IA : dans un projet, elle ne voit et ne pilote QUE ses tâches.
|
||||
func listTasksForProject(slug string) []Task {
|
||||
var out []Task
|
||||
for _, t := range listTasks() {
|
||||
if taskProjectOf(t) == slug {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getTask lit une tâche par ID. Renvoie false si absente.
|
||||
func getTask(id string) (Task, bool) {
|
||||
var t Task
|
||||
|
||||
@@ -149,6 +149,14 @@ const reportMax = 4000
|
||||
func runTask(t Task) {
|
||||
start := time.Now()
|
||||
|
||||
// Tâche « script seul » : aucun modèle à charger, aucun token consommé — on
|
||||
// lance le script du dossier protégé et sa sortie devient le compte-rendu.
|
||||
// Testé AVANT le preset : basculer le moteur pour un script serait absurde.
|
||||
if t.Kind == "script" {
|
||||
runScriptTask(t)
|
||||
return
|
||||
}
|
||||
|
||||
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
|
||||
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
|
||||
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
|
||||
@@ -277,4 +285,15 @@ func recordTaskEnd(id string, start time.Time, report string, err error) {
|
||||
cur.LastReport = report
|
||||
}
|
||||
_ = saveTask(cur)
|
||||
// Notification Web Push : une tâche planifiée tourne SANS personne devant
|
||||
// l'écran — c'est le cas où être prévenu compte le plus. Détaché (l'envoi
|
||||
// HTTP ne doit pas retenir le planificateur) et silencieux sur une
|
||||
// interruption manuelle : celui qui a cliqué « stop » est déjà là.
|
||||
if hasPushSubs() && !errors.Is(err, context.Canceled) {
|
||||
title, body := "Loki · tâche terminée", cur.Name
|
||||
if err != nil {
|
||||
title = "Loki · tâche en échec"
|
||||
}
|
||||
go sendPushToAll(title, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package loki
|
||||
|
||||
// tasks_script.go — exécution d'une tâche « script seul » : on lance un script du
|
||||
// dossier scriptsDir(), SANS charger le modèle ni consommer de tokens. La sortie
|
||||
// du script devient le compte-rendu, exactement comme pour une tâche IA, si bien
|
||||
// que l'UI et les outils task_* l'affichent sans traitement particulier.
|
||||
//
|
||||
// Une tâche script ne passe PAS par le verrou de génération de la conversation
|
||||
// (elle n'utilise pas le modèle), donc conv.runningTaskID ne la voit pas. On tient
|
||||
// donc un petit registre à part (scriptRunning) pour que l'UI l'affiche « en
|
||||
// cours » et puisse l'arrêter.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scriptTaskTimeout borne l'exécution d'un script planifié. Généreux (un backup ou
|
||||
// une synchro peut durer), mais fini : une tâche coincée ne doit pas bloquer le
|
||||
// planificateur à vie.
|
||||
const scriptTaskTimeout = 10 * time.Minute
|
||||
|
||||
// scriptRunning suit les tâches script en cours (id → cancel + nom), pour que
|
||||
// handleTasks les signale « en cours » et que l'arrêt (/api/tasks/stop) puisse
|
||||
// annuler leur contexte.
|
||||
var scriptRunning = struct {
|
||||
mu sync.Mutex
|
||||
cancel map[string]context.CancelFunc
|
||||
name map[string]string
|
||||
}{cancel: map[string]context.CancelFunc{}, name: map[string]string{}}
|
||||
|
||||
func scriptRunBegin(id, name string, cancel context.CancelFunc) {
|
||||
scriptRunning.mu.Lock()
|
||||
scriptRunning.cancel[id] = cancel
|
||||
scriptRunning.name[id] = name
|
||||
scriptRunning.mu.Unlock()
|
||||
}
|
||||
|
||||
func scriptRunEnd(id string) {
|
||||
scriptRunning.mu.Lock()
|
||||
delete(scriptRunning.cancel, id)
|
||||
delete(scriptRunning.name, id)
|
||||
scriptRunning.mu.Unlock()
|
||||
}
|
||||
|
||||
// scriptRunningAny renvoie l'id et le nom d'une tâche script en cours (la
|
||||
// première trouvée), ou "","" si aucune. Le scheduler ne lance qu'une tâche à la
|
||||
// fois, donc en pratique il y en a au plus une.
|
||||
func scriptRunningAny() (string, string) {
|
||||
scriptRunning.mu.Lock()
|
||||
defer scriptRunning.mu.Unlock()
|
||||
for id, n := range scriptRunning.name {
|
||||
return id, n
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// scriptRunStop annule la tâche script en cours d'id `id`. Renvoie true si une
|
||||
// exécution correspondante a été trouvée et annulée.
|
||||
func scriptRunStop(id string) bool {
|
||||
scriptRunning.mu.Lock()
|
||||
c := scriptRunning.cancel[id]
|
||||
scriptRunning.mu.Unlock()
|
||||
if c != nil {
|
||||
c()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runScriptTask exécute le script d'une tâche et enregistre son état (comme runTask
|
||||
// pour une tâche IA), mais sans aucune inférence.
|
||||
func runScriptTask(t Task) {
|
||||
start := time.Now()
|
||||
|
||||
name := strings.TrimSpace(t.Script)
|
||||
if name == "" {
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("aucun script associé à la tâche"))
|
||||
return
|
||||
}
|
||||
full, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
recordTaskEnd(t.ID, start, "", err)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("script introuvable : %s", name))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scriptTaskTimeout)
|
||||
defer cancel()
|
||||
// Registre : rend la tâche visible « en cours » dans l'UI et arrêtable via
|
||||
// /api/tasks/stop (qui appelle scriptRunStop → cancel).
|
||||
scriptRunBegin(t.ID, t.Name, cancel)
|
||||
defer scriptRunEnd(t.ID)
|
||||
|
||||
// On réutilise runShell : garde-fous, dossier de travail, troncature de sortie
|
||||
// et gestion du timeout y sont déjà. La sortie formatée ("exit: N ...") sert à
|
||||
// la fois de compte-rendu et de signal de succès.
|
||||
out := runShell(ctx, scriptRunCommand(full), int(scriptTaskTimeout/time.Second))
|
||||
switch {
|
||||
case ctx.Err() == context.Canceled:
|
||||
// Arrêt volontaire (bouton stop) : recordTaskEnd l'affiche « interrompue »
|
||||
// sans allumer l'indicateur d'échec.
|
||||
recordTaskEnd(t.ID, start, "", context.Canceled)
|
||||
case strings.HasPrefix(out, "exit: 0"):
|
||||
recordTaskEnd(t.ID, start, out, nil)
|
||||
default:
|
||||
// Sortie non nulle, timeout ou erreur de lancement : échec, avec la sortie
|
||||
// complète comme message d'erreur.
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("%s", out))
|
||||
}
|
||||
}
|
||||
|
||||
// scriptRunCommand construit la ligne de commande qui exécute le script `path`
|
||||
// selon la plateforme et l'extension. Sous Unix, on lance le fichier directement
|
||||
// (il est écrit en 0755, le shebang choisit l'interpréteur). Sous Windows, un .ps1
|
||||
// passe par PowerShell ; le reste (.bat/.cmd/.exe) se lance tel quel via cmd. Le
|
||||
// nom de script est déjà bridé par scriptsPath (pas de guillemet ni de métacaractère
|
||||
// shell), donc l'entourer de guillemets suffit.
|
||||
func scriptRunCommand(path string) string {
|
||||
q := `"` + path + `"`
|
||||
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".ps1") {
|
||||
return `powershell -NoProfile -ExecutionPolicy Bypass -File ` + q
|
||||
}
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package loki
|
||||
|
||||
// tasks_tools.go — outils task_* exposés au modèle pour qu'il gère LUI-MÊME ses
|
||||
// tâches planifiées (voir tasks.go) : se donner un rappel récurrent, une veille,
|
||||
// un ménage périodique… puis les lister, les ajuster ou les supprimer. Ils
|
||||
// réutilisent exactement l'infra de l'UI (validateSchedule, computeNextRun,
|
||||
// saveTask), donc une tâche créée par l'IA apparaît et se pilote comme les
|
||||
// autres dans le panneau Tâches.
|
||||
//
|
||||
// Gate : réservés au mode agent (EnabledTools). Une tâche n'a de sens que si
|
||||
// l'IA peut agir en arrière-plan ; sans agent elle n'aurait aucun outil pour
|
||||
// livrer son résultat.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// str extrait une chaîne d'un argument d'outil décodé en any ("" si absent ou
|
||||
// d'un autre type).
|
||||
func taskStrArg(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func taskListTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "task_list",
|
||||
Description: "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "Show only this task, with its full last report"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func taskCreateTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "task_create",
|
||||
Description: "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
"prompt": map[string]any{"type": "string", "description": "AI task: self-contained instruction to run each time (omit if using script)"},
|
||||
"script": map[string]any{"type": "string", "description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved"},
|
||||
// Le format du schedule est le seul point que le modèle ne peut pas
|
||||
// deviner : il vit ici, dans le paramètre, pas dans la phrase de l'outil.
|
||||
"schedule": map[string]any{"type": "string", "description": "\"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\""},
|
||||
"enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"},
|
||||
},
|
||||
"required": []string{"name", "schedule"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func taskUpdateTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "task_update",
|
||||
Description: "Change one or more fields of a scheduled task by id.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string"},
|
||||
"name": map[string]any{"type": "string"},
|
||||
"prompt": map[string]any{"type": "string"},
|
||||
"schedule": map[string]any{"type": "string"},
|
||||
"enabled": map[string]any{"type": "boolean", "description": "false pauses, true resumes"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func taskDeleteTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "task_delete",
|
||||
Description: "Delete a scheduled task by id.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// defaultTaskTZ : le modèle n'a pas le fuseau du navigateur. On reprend celui
|
||||
// d'une tâche existante (posé par l'UI depuis le navigateur) pour que « tous les
|
||||
// jours à 9h » tombe à la bonne heure ; à défaut, fuseau vide = heure locale du
|
||||
// serveur (souvent UTC), ce que la description du planificateur assume déjà.
|
||||
func defaultTaskTZ() string {
|
||||
for _, t := range listTasks() {
|
||||
if strings.TrimSpace(t.TZ) != "" {
|
||||
return t.TZ
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toolTaskList formate les tâches pour le modèle. Avec un id, on ne montre QUE
|
||||
// cette tâche, compte-rendu COMPLET (le cas « donne-moi le compte-rendu de la
|
||||
// tâche X ») ; sans id, la liste complète avec un aperçu court par tâche.
|
||||
func toolTaskList(args map[string]any) string {
|
||||
proj := activeProjectSlug()
|
||||
tasks := listTasksForProject(proj)
|
||||
if id := strings.TrimSpace(taskStrArg(args["id"])); id != "" {
|
||||
// On ne révèle que les tâches du projet actif : une tâche d'un autre projet
|
||||
// est traitée comme inexistante (même message que si l'id était faux).
|
||||
t, ok := getTask(id)
|
||||
if !ok || taskProjectOf(t) != proj {
|
||||
return "[erreur] tâche introuvable : " + id
|
||||
}
|
||||
return formatTask(t, true)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return "[aucune tâche planifiée dans ce projet]"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, t := range tasks {
|
||||
b.WriteString(formatTask(t, false) + "\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// formatTask rend une tâche. full=true : compte-rendu entier (sur ses propres
|
||||
// lignes) ; full=false : aperçu court d'une ligne, pour une liste scannable.
|
||||
func formatTask(t Task, full bool) string {
|
||||
var b strings.Builder
|
||||
state := "off"
|
||||
if t.Enabled {
|
||||
state = "on"
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s [%s] « %s » — %s", t.ID, state, t.Name, t.Schedule)
|
||||
if t.NextRun > 0 && t.Enabled {
|
||||
fmt.Fprintf(&b, " (prochaine : %s)", time.UnixMilli(t.NextRun).In(loc(t.TZ)).Format("2006-01-02 15:04"))
|
||||
}
|
||||
if t.LastRun > 0 {
|
||||
when := time.UnixMilli(t.LastRun).In(loc(t.TZ)).Format("2006-01-02 15:04")
|
||||
if t.LastOK {
|
||||
fmt.Fprintf(&b, " ✓ dernier run %s", when)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " ✗ dernier run %s : %s", when, t.LastError)
|
||||
}
|
||||
if r := strings.TrimSpace(t.LastReport); r != "" {
|
||||
if full {
|
||||
// Compte-rendu ENTIER (déjà borné à 4000 car au stockage), tel quel,
|
||||
// sur ses propres lignes — c'est ce que l'utilisateur demande.
|
||||
b.WriteString("\n compte-rendu complet :\n" + r)
|
||||
} else {
|
||||
b.WriteString("\n compte-rendu : " + previewReport(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// previewReport aplati + tronque le compte-rendu pour l'aperçu de la liste (un
|
||||
// rapport entier par tâche gonflerait la sortie). Le texte complet s'obtient via
|
||||
// task_list avec l'id de la tâche.
|
||||
func previewReport(s string) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
const max = 300
|
||||
if len(s) > max {
|
||||
return s[:max] + " […] (id pour le compte-rendu complet)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// toolTaskCreate crée une tâche depuis un appel d'outil du modèle.
|
||||
func toolTaskCreate(args map[string]any) string {
|
||||
name := strings.TrimSpace(taskStrArg(args["name"]))
|
||||
prompt := strings.TrimSpace(taskStrArg(args["prompt"]))
|
||||
script := strings.TrimSpace(taskStrArg(args["script"]))
|
||||
schedule := strings.TrimSpace(taskStrArg(args["schedule"]))
|
||||
if name == "" || schedule == "" {
|
||||
return "[erreur] name et schedule sont obligatoires"
|
||||
}
|
||||
kind := ""
|
||||
if script != "" {
|
||||
// Tâche script : le script doit exister dans le dossier protégé.
|
||||
kind = "script"
|
||||
if err := scriptExists(script); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
} else if prompt == "" {
|
||||
return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)"
|
||||
}
|
||||
tz := defaultTaskTZ()
|
||||
if err := validateSchedule(schedule, tz); err != nil {
|
||||
return "[erreur] fréquence invalide : " + err.Error()
|
||||
}
|
||||
enabled := true
|
||||
if v, ok := args["enabled"].(bool); ok {
|
||||
enabled = v
|
||||
}
|
||||
t := Task{
|
||||
ID: newTaskID(), Name: name, Prompt: prompt, Schedule: schedule,
|
||||
TZ: tz, Enabled: enabled, Kind: kind, Script: script,
|
||||
// Rattache la tâche au projet actif : l'IA ne crée que DANS son projet,
|
||||
// comme elle n'y voit et n'y pilote que ses propres tâches.
|
||||
Project: activeProjectSlug(),
|
||||
}
|
||||
t.NextRun = computeNextRun(schedule, tz, time.Now())
|
||||
if err := saveTask(t); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
when := ""
|
||||
if t.NextRun > 0 && enabled {
|
||||
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04")
|
||||
}
|
||||
return fmt.Sprintf("[ok] tâche '%s' créée (id %s)%s", name, t.ID, when)
|
||||
}
|
||||
|
||||
// toolTaskUpdate modifie une tâche existante (partiel).
|
||||
func toolTaskUpdate(args map[string]any) string {
|
||||
id := strings.TrimSpace(taskStrArg(args["id"]))
|
||||
if id == "" {
|
||||
return "[erreur] id manquant"
|
||||
}
|
||||
t, ok := getTask(id)
|
||||
if !ok || taskProjectOf(t) != activeProjectSlug() {
|
||||
return "[erreur] tâche introuvable : " + id
|
||||
}
|
||||
if v, ok := args["name"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
t.Name = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := args["prompt"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
t.Prompt = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := args["schedule"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
sched := strings.TrimSpace(v)
|
||||
if err := validateSchedule(sched, t.TZ); err != nil {
|
||||
return "[erreur] fréquence invalide : " + err.Error()
|
||||
}
|
||||
t.Schedule = sched
|
||||
t.NextRun = computeNextRun(sched, t.TZ, time.Now())
|
||||
}
|
||||
if v, ok := args["enabled"].(bool); ok {
|
||||
t.Enabled = v
|
||||
if v && t.NextRun == 0 {
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
|
||||
}
|
||||
}
|
||||
if err := saveTask(t); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] tâche '%s' (id %s) mise à jour", t.Name, t.ID)
|
||||
}
|
||||
|
||||
// toolTaskDelete supprime une tâche.
|
||||
func toolTaskDelete(args map[string]any) string {
|
||||
id := strings.TrimSpace(taskStrArg(args["id"]))
|
||||
if id == "" {
|
||||
return "[erreur] id manquant"
|
||||
}
|
||||
t, ok := getTask(id)
|
||||
if !ok || taskProjectOf(t) != activeProjectSlug() {
|
||||
return "[erreur] tâche introuvable : " + id
|
||||
}
|
||||
if err := deleteTask(id); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] tâche '%s' (id %s) supprimée", t.Name, id)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func trackerStoreKey(proj, slug string) string { return proj + "/" + slug }
|
||||
// trackerLoad lit un tracker du projet actif par son slug. false si absent.
|
||||
func trackerLoad(slug string) (*Tracker, bool) {
|
||||
var s Tracker
|
||||
if !getJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), &s) {
|
||||
if !getStoreJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), &s) {
|
||||
return nil, false
|
||||
}
|
||||
return &s, true
|
||||
@@ -55,7 +55,7 @@ func trackerLoad(slug string) (*Tracker, bool) {
|
||||
|
||||
func trackerSave(slug string, s *Tracker) error {
|
||||
sort.SliceStable(s.Events, func(i, j int) bool { return s.Events[i].TS < s.Events[j].TS })
|
||||
return putJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), s)
|
||||
return putStoreJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), s)
|
||||
}
|
||||
|
||||
// trackerKeysForProject renvoie les clés brutes du bucket appartenant à un projet.
|
||||
|
||||
+814
-22
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Loki",
|
||||
"short_name": "Loki",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#5E7F5A",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
|
||||
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAYAAAA9zQYyAAADD0lEQVR4nOzdW27aWgBGYag6ylZqh1I6lFZKppmjEylRLlyMsbG9+n3vsP2w8mfDC193d/Dj17ene5zDuv39/bif+4xZDhAwQ8wR+GRvKGJuMVXcN7+JkJnSrWGPfrGQmdPYsL+MeZGYmdvYxq76KxAyS7hmrQcvtJhZyjXtDQpazCxtaIMXgxYzazGkxbNBi5m1udTkyaDFzFqda3PU13awVkeDts6s3alGPwUtZrbiWKuuHKS8C9o6szUfm7XQpLwGbZ3ZqrftWmhSBE3Kc9CuG2zdS8MWmhRBkyJoUvbuz5RYaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSbnLj9ez2/05PCxy7s/D90XOXYqFJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAialK9LP8C9/Tk8LHLuz8P3Rc7911hoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZl/+PXt6elHwKmYqFJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgibly9/fj/ulHwKm8H/LFpoUQZMiaFKeg3aPZuteGrbQpAialNegXTvYqrftWmhS3gVtpdmaj81aaFI+BW2l2YpjrR5daFGzdqcadeUg5WTQVpq1Otfm2YUWNWtzqcmLVw5RsxZDWhx0hxY1Sxva4OAPhaJmKde0NypSPwXHPYwZ0VFf21lr5ja2sZvDtNZM6daxnGxphc0tpvqvP8vVQdwMMcfV9S53YYGzu9Nnr/8CAAD//xZRk2OJYiOqAAAAAElFTkSuQmCC">
|
||||
<!-- Manifeste PWA : installabilité Android/desktop et, surtout, Web Push. iOS
|
||||
s'appuie sur les métas apple-* ci-dessus ; le manifeste ne le gêne pas.
|
||||
Servi à la racine par web_server.go, comme /sw.js. -->
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<!-- Marque le mode PWA AVANT le rendu : la hauteur de coque et le défilement du
|
||||
document en dépendent (voir styles.css). ⚠️ On NE PEUT PAS se fier au média
|
||||
`display-mode:standalone` : WebKit l'adosse au `display` du MANIFESTE, or cette
|
||||
@@ -115,7 +119,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||
</button>
|
||||
<div id="topbar-title">Nouvelle discussion</div>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="preset actif — en changer recharge tous ses réglages et relance le moteur" aria-label="preset actif"></select>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
@@ -360,6 +364,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<section class="set-pane" data-pane="agent">
|
||||
<div class="set-pane-h"><span>Mode agent</span></div>
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||
<!-- Notifications Web Push. Indépendantes du mode agent (une réponse de chat
|
||||
prête est utile à savoir sans aucun outil), donc HORS du groupe grisé.
|
||||
À régler sur CHAQUE appareil : l'abonnement appartient au navigateur. -->
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="push-toggle" onchange="togglePush()"><span class="slider"></span></span> me prévenir quand c'est prêt<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le serveur envoie une <b>notification</b> à la fin d'une réponse et à la fin d'une <b>tâche planifiée</b>, même l'app fermée ou le téléphone verrouillé. Demande <b>HTTPS</b> (ou localhost). Sur iPhone : <b>ajoute d'abord Loki à l'écran d'accueil</b>, puis active depuis l'app installée. À régler sur chaque appareil.</span></span></label>
|
||||
<div class="rowbody"><div id="push-status" class="muted" style="font-size:11px"></div></div>
|
||||
</div>
|
||||
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
||||
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
||||
entier est grisé/inerte quand l'agent est off (voir setAgentGate). -->
|
||||
@@ -373,6 +383,22 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<option value="off">désactivée</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Chiffrement au repos. La clé est celle de pilotage de CET appareil
|
||||
(localStorage) : le serveur n'en a que l'empreinte, il ne peut pas
|
||||
ouvrir le coffre seul. Couvre les pages, les discussions, les blocs
|
||||
archivés au compactage et les trackers. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="mem-enc-toggle" onchange="toggleMemEncrypt()"><span class="slider"></span></span> chiffrer la mémoire sur le disque<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Chiffre en <b>AES-256-GCM</b> les pages mémoire, les discussions, les blocs archivés et les trackers sous <code>/data</code>. La clé de données est enfermée dans un coffre (Argon2id) ouvert par la <b>clé de pilotage de cet appareil</b> ou par une <b>clé de récupération</b> donnée une seule fois à l'activation. Un <b>snapshot</b> est pris avant chaque bascule, et rien n'est supprimé avant relecture vérifiée.</span></span></label>
|
||||
<div class="rowbody"><div id="mem-enc-status" class="muted" style="font-size:11px"></div></div>
|
||||
<!-- Sauvegarde : le MÊME paquet chiffré que l'amont envoie à son relais,
|
||||
mais en fichier local — rien ne quitte la machine sans qu'on le
|
||||
demande. Nécessite le chiffrement actif : c'est sa clé qui scelle. -->
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Sauvegarde chiffrée</span>
|
||||
<span class="pe-row-c">
|
||||
<button onclick="backupExport()">exporter</button>
|
||||
<button onclick="backupImport()">importer</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="mem-pages-bar" class="pages-bar" onclick="toggleMemPages()">
|
||||
<span class="pages-title">Voir la mémoire <span id="mem-count" class="mem-count"></span></span>
|
||||
<button class="addbtn" onclick="event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button>
|
||||
@@ -434,6 +460,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pilotage de navigateur (computer use) : axe de capacité à part, sous le
|
||||
mode agent comme l'accès internet — un outil qui clique et tape dans une
|
||||
page fait des actions RÉELLES, même niveau de confiance que bash. -->
|
||||
<div id="computer-block" class="gated-group">
|
||||
<div class="subhead">Contrôle du navigateur<span id="computer-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Donne à l'IA le contrôle d'un <b>navigateur</b> dans le conteneur (outils <b>browser_open / browser_find / browser_click / browser_type / browser_key / browser_scroll</b>). L'IA lit les éléments <b>numérotés</b> de la page et agit par numéro — aucune vision requise, ça marche avec de petits modèles. <code>browser_screenshot</code> s'ajoute si la vision est active. Utilise le Chromium de Playwright embarqué dans l'image ; à défaut, <code>LOKI_CHROME=<chemin></code>. Actif seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="computer-toggle" onchange="toggleComputer()"><span class="slider"></span></span> laisser l'IA piloter un navigateur</label>
|
||||
<div class="rowbody">
|
||||
<div id="computer-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-block" class="gated-group">
|
||||
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
|
||||
<div class="slist">
|
||||
@@ -621,6 +659,15 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<section class="set-pane" data-pane="appearance">
|
||||
<div class="set-pane-h"><span>Apparence</span></div>
|
||||
<div class="slist">
|
||||
<!-- Langue de l'interface. La source est française ; « English » applique
|
||||
un dictionnaire sur la coque (navigation, libellés, boutons) — ce qui
|
||||
n'y figure pas reste en français plutôt que d'afficher une clé. -->
|
||||
<label class="switchrow"><span>Langue</span>
|
||||
<select id="ui-lang" class="sctl" onchange="setUiLang(this.value)">
|
||||
<option value="fr">Français</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="switchrow rev"><span>mode sombre</span>
|
||||
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
||||
</label>
|
||||
@@ -914,11 +961,34 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type de tâche : une consigne exécutée par l'IA, ou un script du dossier
|
||||
protégé lancé SEUL — sans charger le modèle ni consommer de tokens.
|
||||
Une sauvegarde, une synchro, un nettoyage n'ont rien à demander à un
|
||||
LLM ; les faire passer par lui coûtait un chargement de modèle. -->
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Type</div>
|
||||
<div class="pe-seg">
|
||||
<label><input type="radio" name="task-kind" value="agent" onchange="taskKindUI('agent')"><span>Consigne IA</span></label>
|
||||
<label><input type="radio" name="task-kind" value="script" onchange="taskKindUI('script')"><span>Script seul</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group" id="task-prompt-group">
|
||||
<div class="pe-gh">Consigne</div>
|
||||
<textarea id="task-prompt" class="pe-inp task-consigne" spellcheck="false" placeholder="Ce que l'IA doit faire, et comment livrer le résultat. Tu peux détailler librement : contexte, étapes, format du compte-rendu attendu…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="pe-group" id="task-script-group" style="display:none">
|
||||
<div class="pe-gh">Script</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">À exécuter</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="task-script"></select></span></span>
|
||||
</div>
|
||||
<div class="pe-row stack plain"><div id="task-script-hint" class="muted" style="font-size:11px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Preset</div>
|
||||
<div class="pe-list">
|
||||
|
||||
@@ -128,12 +128,13 @@ async function loadAgent(){
|
||||
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
|
||||
document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none';
|
||||
renderMemList();
|
||||
loadMemEnc();
|
||||
}
|
||||
// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni
|
||||
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
|
||||
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
|
||||
function setAgentGate(on){
|
||||
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
|
||||
['mem-block','net-block','computer-block','mcp-block','param-block'].forEach(id=>{
|
||||
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
|
||||
});
|
||||
}
|
||||
@@ -185,6 +186,120 @@ function renderMemList(){
|
||||
}
|
||||
// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire
|
||||
const loadMem = loadAgent;
|
||||
|
||||
// --- Chiffrement de la mémoire (pages, discussions, blocs archivés, trackers) -
|
||||
//
|
||||
// La clé de chiffrement EST la clé de pilotage (localStorage 'loki.key'), qui ne
|
||||
// vit que sur cet appareil — le serveur n'en garde qu'une empreinte, il ne peut
|
||||
// donc pas ouvrir le coffre seul. Une seule clé pour l'accès ET le chiffrement :
|
||||
// avoir l'interface sous les yeux suffit à déverrouiller, sans second secret à
|
||||
// retenir.
|
||||
let memUnlockDone=false; // tentative de déverrouillage déjà faite ce chargement
|
||||
async function loadMemEnc(){
|
||||
let h; try{ h=await jget('/api/mem/health'); }catch(e){ return; }
|
||||
const tog=document.getElementById('mem-enc-toggle'); if(!tog) return;
|
||||
// La case n'est cochée QUE si TOUT est réellement chiffré (h.fully). Un
|
||||
// chiffrement partiel, raté ou verrouillé-incomplet la laisse décochée : si
|
||||
// elle est cochée, c'est que ça l'est vraiment.
|
||||
tog.checked=!!h.fully;
|
||||
const st=document.getElementById('mem-enc-status');
|
||||
if(st){
|
||||
if(!h.encrypted) st.textContent='pages, discussions, blocs archivés et trackers sont en clair sous /data.';
|
||||
else if(h.locked) st.textContent='chiffré — verrouillé : rien n\'est lisible tant que la clé n\'est pas fournie.';
|
||||
else st.textContent='chiffré et déverrouillé · '+(h.pages|0)+' page(s)'+(h.mixed?' ⚠ certaines pages sont encore en clair':'');
|
||||
}
|
||||
// Résilience : si le drapeau a disparu mais qu'un coffre existe encore
|
||||
// (vault_copies>0), on propose QUAND MÊME le déverrouillage — sinon la
|
||||
// mémoire reste muette et rien ne demande la clé.
|
||||
const flagLost = !h.encrypted && (h.vault_copies|0) > 0;
|
||||
if(((h.encrypted && h.locked) || flagLost) && !memUnlockDone){
|
||||
memUnlockDone=true;
|
||||
const apiKey=localStorage.getItem('loki.key')||'';
|
||||
if(apiKey){ const r=await jpost('/api/mem/unlock',{secret:apiKey}); if(r.ok){ loadMem(); return; } }
|
||||
// Boucle : une clé fausse redemande tout de suite (pas besoin de recharger).
|
||||
while(true){
|
||||
const s2=await askPrompt('Saisis la clé de pilotage de cet appareil, ou la clé de récupération notée à l\'activation.',{title:'Mémoire verrouillée',placeholder:'clé de pilotage ou clé de récupération'});
|
||||
if(!s2){ memUnlockDone=false; break; } // annulé : on redemandera au prochain chargement
|
||||
const r=await jpost('/api/mem/unlock',{secret:s2});
|
||||
if(r.ok){ loadMem(); return; }
|
||||
await askAlert('Clé refusée.',{title:'Refusé'});
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Sauvegarde chiffrée en fichier (mémoire + presets + réglages) ----------
|
||||
// Le corps est scellé avec la clé de la mémoire ; la restauration ne demande que
|
||||
// la clé de pilotage (ou la clé de récupération). Aucun relais : c'est un
|
||||
// fichier, que tu ranges où tu veux.
|
||||
async function backupExport(){
|
||||
let r;
|
||||
try{ r = await jfetch('/api/backup/export'); }
|
||||
catch(e){ await askAlert('échec : '+e); return; }
|
||||
if(!r.ok){
|
||||
let msg='échec'; try{ const j=await r.json(); msg=j.error||msg; }catch(_){}
|
||||
await askAlert(msg, {title:'Sauvegarde impossible'});
|
||||
return;
|
||||
}
|
||||
const blob = await r.blob();
|
||||
// Le nom vient du serveur (Content-Disposition) ; repli daté côté client.
|
||||
let name = 'loki-backup.lkbk';
|
||||
const cd = r.headers.get('Content-Disposition')||'';
|
||||
const m = /filename="([^"]+)"/.exec(cd);
|
||||
if(m) name = m[1];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(url), 10000);
|
||||
toast('sauvegarde téléchargée');
|
||||
}
|
||||
async function backupImport(){
|
||||
if(!await askConfirm("La mémoire, les presets et les réglages de CE serveur seront remplacés par ceux du paquet. Un snapshot de la mémoire actuelle est pris avant.",{title:'Restaurer une sauvegarde ?',okText:'Choisir le fichier',danger:true})) return;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = '.lkbk,application/octet-stream';
|
||||
inp.onchange = async () => {
|
||||
const f = inp.files && inp.files[0];
|
||||
if(!f) return;
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
// btoa par tranches : un spread sur un tableau de plusieurs Mo fait sauter
|
||||
// la pile d'appels.
|
||||
let bin = '';
|
||||
for(let i=0;i<buf.length;i+=0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i+0x8000));
|
||||
const data = btoa(bin);
|
||||
// Boucle : une clé fausse redemande tout de suite.
|
||||
while(true){
|
||||
const secret = await askPrompt("Clé de pilotage de la machine d'origine, ou clé de récupération.",{title:'Restaurer',placeholder:'clé'});
|
||||
if(!secret) return;
|
||||
toast('restauration…');
|
||||
let r; try{ r = await jpost('/api/backup/import',{data, secret}); }catch(e){ await askAlert('échec : '+e); return; }
|
||||
if(r && r.ok){ await askAlert('Sauvegarde restaurée.',{title:'Terminé'}); loadMem(); return; }
|
||||
await askAlert((r&&r.error)||'échec',{title:'Refusé'});
|
||||
}
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
async function toggleMemEncrypt(){
|
||||
const tog=document.getElementById('mem-enc-toggle');
|
||||
if(tog.checked){
|
||||
// Déjà chiffré mais incomplet (verrouillé, ou discussions à migrer) : on ne
|
||||
// relance PAS un chiffrement, on complète en déverrouillant.
|
||||
let cur; try{ cur=await jget('/api/mem/health'); }catch(e){}
|
||||
if(cur && cur.encrypted){ memUnlockDone=false; await loadMemEnc(); return; }
|
||||
const apiKey=localStorage.getItem('loki.key')||'';
|
||||
if(!apiKey){ await askAlert("Le chiffrement utilise la clé de pilotage de cet appareil, et il n'y en a pas. Règle-la d'abord (loki set-web-key), puis reviens.",{title:'Clé de pilotage requise'}); tog.checked=false; return; }
|
||||
const ok=await askConfirm("Les pages mémoire, les discussions, les blocs archivés et les trackers seront chiffrés sur le disque. La clé de cet appareil les ouvre ; une clé de récupération te sera donnée UNE fois — note-la, elle est ta seule porte de secours.",{title:'Chiffrer la mémoire ?',okText:'Chiffrer'});
|
||||
if(!ok){ tog.checked=false; return; }
|
||||
let r; try{ r=await jpost('/api/mem/encrypt',{password:apiKey}); }catch(e){ await askAlert('échec : '+e); tog.checked=false; return; }
|
||||
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=false; loadMemEnc(); return; }
|
||||
memUnlockDone=true;
|
||||
await askAlert("Note cette clé de récupération, elle ne sera plus jamais affichée :\n\n"+r.recovery,{title:'Clé de récupération'});
|
||||
toast('chiffrement activé'); loadMem();
|
||||
} else {
|
||||
const ok=await askConfirm('Tout sera réécrit EN CLAIR sur le disque.',{title:'Déchiffrer la mémoire ?',okText:'Déchiffrer',danger:true});
|
||||
if(!ok){ tog.checked=true; return; }
|
||||
let r; try{ r=await jpost('/api/mem/decrypt',{}); }catch(e){ await askAlert('échec : '+e); tog.checked=true; return; }
|
||||
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=true; loadMemEnc(); return; }
|
||||
toast('chiffrement désactivé'); loadMem();
|
||||
}
|
||||
}
|
||||
async function toggleAgent(){
|
||||
const on=document.getElementById('agent-toggle').checked;
|
||||
if(on && !await askConfirm("L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.", {title:'Activer le mode agent ?', okText:'Activer', danger:true})){ document.getElementById('agent-toggle').checked=false; return; }
|
||||
@@ -242,6 +357,32 @@ function renderInternet(s){
|
||||
else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; }
|
||||
}
|
||||
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
|
||||
// Contrôle du navigateur (outils browser_*). Le serveur dit s'il a trouvé un
|
||||
// binaire Chromium : sans lui, les outils ne sont pas annoncés au modèle, et
|
||||
// l'interrupteur ne servirait qu'à promettre ce qui n'existe pas.
|
||||
function renderComputer(s){
|
||||
const tg=document.getElementById('computer-toggle');
|
||||
if(tg) tg.checked = !!s.enabled;
|
||||
// La pastille ne redit pas l'interrupteur : elle ne signale que l'anomalie
|
||||
// (actif mais aucun navigateur trouvé).
|
||||
if(s.enabled && !s.browser_ok) setBadge('computer-badge','warn','sans navigateur');
|
||||
else setBadge('computer-badge', null);
|
||||
const st=document.getElementById('computer-status');
|
||||
if(!st) return;
|
||||
if(!s.enabled){
|
||||
st.textContent = "inactif : l'IA n'a aucun outil de navigateur" + (s.browser_ok ? '.' : " (aucun navigateur détecté dans le conteneur).");
|
||||
st.style.color='';
|
||||
return;
|
||||
}
|
||||
if(!s.browser_ok){ st.innerHTML = '⚠ aucun Chromium détecté — image bâtie avec PLAYWRIGHT=0 ? sinon définis LOKI_CHROME=<chemin du binaire>'; return; }
|
||||
st.innerHTML = '<span style="color:var(--accent)">✓</span> navigateur prêt, outils de contrôle actifs'
|
||||
+ (s.vision ? '' : ' — vision inactive : browser_screenshot indisponible');
|
||||
}
|
||||
async function loadComputer(){ renderComputer(await jget('/api/computer')); }
|
||||
async function toggleComputer(){
|
||||
const on=document.getElementById('computer-toggle').checked;
|
||||
renderComputer(await jpost('/api/computer',{on}));
|
||||
}
|
||||
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
|
||||
let OAI_KEY='', OAI_REVEAL=false;
|
||||
async function copyText(txt, msg){
|
||||
@@ -473,7 +614,7 @@ async function loadAll(){
|
||||
// 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(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
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); }
|
||||
@@ -286,7 +286,10 @@ function renderToolMsg(el, tu){
|
||||
meta = meta || {lbl:'mémoire', head:'mémoire'};
|
||||
let lbl = meta.lbl;
|
||||
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
|
||||
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
|
||||
// Taille RÉELLE (result_chars) quand le flux n'a envoyé qu'un aperçu — sinon
|
||||
// la bulle affichait toujours le même chiffre, celui du plafond d'aperçu.
|
||||
const realChars = tu.result_chars || (tu.result ? tu.result.length : 0);
|
||||
if(realChars){ lbl += ' · ~' + Math.max(1, Math.round(realChars/4)) + ' tok'; }
|
||||
setLabel(el, lbl);
|
||||
// Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
|
||||
// reporté sur l'étiquette, pour rester lisible bulle repliée.
|
||||
@@ -365,8 +368,49 @@ function renderToolMsg(el, tu){
|
||||
const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre');
|
||||
const code=document.createElement('code'); code.textContent=tu.result;
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
const code=document.createElement('code');
|
||||
const preview=tu.result, rid=tu.result_id, PREVIEW=1600;
|
||||
// Deux cas :
|
||||
// - result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
|
||||
// « voir plus » CHARGE le reste à la demande via l'API ;
|
||||
// - sinon : le résultat est déjà complet ; s'il est long, « voir plus » le
|
||||
// déplie localement (rien à charger).
|
||||
if(rid){
|
||||
code.textContent=preview+'…';
|
||||
pre.appendChild(code);
|
||||
const more=document.createElement('button');
|
||||
more.className='tool-more'; more.type='button'; more.textContent='voir plus';
|
||||
let full=null, open=false;
|
||||
more.onclick=async(e)=>{ e.stopPropagation();
|
||||
if(full===null){
|
||||
more.disabled=true; more.textContent='…';
|
||||
// Résultat introuvable côté serveur (compacté, autre discussion) : on
|
||||
// garde l'aperçu plutôt que de vider la bulle.
|
||||
try{ const j=await jget('/api/chat/tool-result?id='+encodeURIComponent(rid)); full=(j&&j.result)||preview; }
|
||||
catch(_){ full=preview; }
|
||||
more.disabled=false;
|
||||
}
|
||||
open=!open;
|
||||
code.textContent = open ? full : preview+'…';
|
||||
pre.classList.toggle('expanded', open);
|
||||
more.textContent = open ? 'voir moins' : 'voir plus';
|
||||
};
|
||||
body.appendChild(toolBlock(pre, more));
|
||||
} else if(preview.length>PREVIEW){
|
||||
code.textContent=preview.slice(0,PREVIEW)+'…';
|
||||
pre.appendChild(code);
|
||||
const more=document.createElement('button');
|
||||
more.className='tool-more'; more.type='button'; more.textContent='voir plus';
|
||||
let open=false;
|
||||
more.onclick=(e)=>{ e.stopPropagation(); open=!open;
|
||||
code.textContent = open ? preview : preview.slice(0,PREVIEW)+'…';
|
||||
pre.classList.toggle('expanded', open);
|
||||
more.textContent = open ? 'voir moins' : 'voir plus';
|
||||
};
|
||||
body.appendChild(toolBlock(pre, more));
|
||||
} else {
|
||||
code.textContent=preview; pre.appendChild(code); body.appendChild(pre);
|
||||
}
|
||||
} else if(!tu.done && !tu.typing){
|
||||
const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…';
|
||||
body.appendChild(wait);
|
||||
@@ -378,10 +422,26 @@ function renderToolMsg(el, tu){
|
||||
const bw = el.querySelector('.bodywrap');
|
||||
if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
|
||||
}
|
||||
// Bloc d'un résultat d'outil : le pre scrolle (max-height), donc la barre
|
||||
// d'actions vit sur un bloc-parent NON scrollant, ancrée en bas à droite — elle
|
||||
// reste au coin quoi qu'on scrolle dans le résultat. La barre réunit « voir
|
||||
// plus » (passé ici) et, ajouté ensuite par addCopyButtons, « copier ».
|
||||
function toolBlock(pre, more){
|
||||
const block=document.createElement('div'); block.className='tool-block';
|
||||
block.appendChild(pre);
|
||||
const bar=document.createElement('div'); bar.className='tool-actions';
|
||||
bar.appendChild(more);
|
||||
block.appendChild(bar);
|
||||
return block;
|
||||
}
|
||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
root.querySelectorAll('pre').forEach(pre=>{
|
||||
if(pre.querySelector('.copybtn')) return;
|
||||
// Résultat d'outil : le pre est enrobé dans .tool-block et la barre
|
||||
// d'actions est SŒUR du pre (pas dedans) ; on cherche donc le copier dans
|
||||
// ce périmètre.
|
||||
const scope = pre.closest('.tool-block') || pre;
|
||||
if(scope.querySelector('.copybtn')) return;
|
||||
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
|
||||
if(pre.classList.contains('diff')) return;
|
||||
const btn=document.createElement('button');
|
||||
@@ -394,7 +454,9 @@ function addCopyButtons(root){
|
||||
btn.textContent='copié ✓'; btn.classList.add('done');
|
||||
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
|
||||
};
|
||||
pre.appendChild(btn);
|
||||
// S'il y a une barre d'actions (résultat d'outil avec « voir plus »), le
|
||||
// bouton copier s'y range à côté ; sinon il se colle en bas à droite du bloc.
|
||||
(scope.querySelector('.tool-actions')||pre).appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
|
||||
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
|
||||
let lastSeq=0, streamAbort=null;
|
||||
// REPLAY_FULL : demander le journal ENTIER au prochain abonnement. Le serveur
|
||||
// ne rejoue par défaut que la fin d'une longue discussion (voir replayTailMax) ;
|
||||
// le bandeau « charger le début » rouvre le flux avec ce drapeau.
|
||||
let REPLAY_FULL=false;
|
||||
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
|
||||
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
|
||||
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
|
||||
@@ -403,10 +407,44 @@ function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoot
|
||||
// bulle d'outil) doit voir le texte complet.
|
||||
function settleBlocks(){ smoothSnap(); flushRender(); }
|
||||
|
||||
// showReplayBanner affiche, en tête du fil, le bandeau « début masqué ». Un clic
|
||||
// coupe le flux et se réabonne depuis zéro en demandant TOUT le journal.
|
||||
function showReplayBanner(hidden){
|
||||
const c=chatEl(); if(!c) return;
|
||||
let b=document.getElementById('replay-banner');
|
||||
if(!b){
|
||||
b=document.createElement('div');
|
||||
b.id='replay-banner'; b.className='replay-banner';
|
||||
c.insertBefore(b, c.firstChild);
|
||||
}
|
||||
b.textContent='';
|
||||
const txt=document.createElement('span');
|
||||
txt.textContent='Début de la discussion masqué ('+hidden+' événements) — ';
|
||||
const btn=document.createElement('button');
|
||||
btn.type='button'; btn.className='replay-more'; btn.textContent='charger le début';
|
||||
btn.onclick=()=>{ btn.disabled=true; btn.textContent='chargement…'; loadFullReplay(); };
|
||||
b.appendChild(txt); b.appendChild(btn);
|
||||
}
|
||||
|
||||
// loadFullReplay relance l'abonnement depuis le début, journal entier. On repart
|
||||
// d'un fil VIDE : rejouer par-dessus l'existant ferait doublon.
|
||||
function loadFullReplay(){
|
||||
REPLAY_FULL=true; lastSeq=0; REPLAYING=true;
|
||||
if(streamAbort) streamAbort.abort();
|
||||
const c=chatEl(); if(c){ c.innerHTML=''; c.style.opacity='0'; }
|
||||
PENDING=null; newTurn();
|
||||
setChatLoading('chargement du début de la conversation…');
|
||||
connectStream();
|
||||
}
|
||||
|
||||
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
|
||||
// piloté par le serveur et rejouable à l'identique.
|
||||
function handleDelta(d){
|
||||
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
|
||||
// Replay borné : le serveur a sauté le début d'une longue discussion. On
|
||||
// l'annonce en tête du fil, avec le bouton qui va le chercher. Rien n'est
|
||||
// perdu : le journal complet est sur le disque.
|
||||
if(typeof d.replay_truncated==='number'){ showReplayBanner(d.replay_truncated); return; }
|
||||
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
|
||||
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
|
||||
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
|
||||
@@ -557,7 +595,7 @@ async function connectStream(){
|
||||
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
|
||||
streamAbort=new AbortController();
|
||||
try{
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq, full:REPLAY_FULL}),signal:streamAbort.signal});
|
||||
if(REPLAYING) setChatLoading('chargement de la conversation…');
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
|
||||
@@ -48,18 +48,17 @@ function openDetails(el){
|
||||
}
|
||||
}
|
||||
|
||||
// Sélecteur de modèle de l'en-tête. Il ne liste QUE des presets ; en choisir un
|
||||
// revient à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||
// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
|
||||
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||
// confirmation puis relance le moteur).
|
||||
//
|
||||
// Il a listé un temps les .gguf du disque, qu'on chargeait en n'écrivant que
|
||||
// MODEL= : le contexte, le NGL et l'échantillonnage du modèle PRÉCÉDENT
|
||||
// restaient en place. Un 27B chargé par-dessus les réglages d'un 4B partait sur
|
||||
// 100k de contexte et mourait en OOM, sans que rien ne dise pourquoi. Charger un
|
||||
// modèle, c'est appliquer TOUS ses réglages : ça s'appelle un preset, et c'est
|
||||
// le seul chemin.
|
||||
function onModelSwitch(sel){
|
||||
const v = sel.value;
|
||||
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
|
||||
// active garde son contexte et son échantillonnage, seul MODEL change.
|
||||
if(v && v.startsWith('m:')){
|
||||
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
|
||||
return;
|
||||
}
|
||||
const n = parseInt(v, 10);
|
||||
if(!n) return;
|
||||
const name = sel.options[sel.selectedIndex].textContent;
|
||||
@@ -69,6 +68,22 @@ function onModelSwitch(sel){
|
||||
switchTo(n, name);
|
||||
}
|
||||
|
||||
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
|
||||
// revient sur le modèle réellement chargé.
|
||||
async function switchModelFile(value, name){
|
||||
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
|
||||
paintModelSwitch();
|
||||
return;
|
||||
}
|
||||
toast('changement de modèle…');
|
||||
let r = null;
|
||||
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
|
||||
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
|
||||
toast('modèle en cours de chargement…');
|
||||
loadAll();
|
||||
}
|
||||
|
||||
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
||||
//
|
||||
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
||||
@@ -81,12 +96,22 @@ function onModelSwitch(sel){
|
||||
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
||||
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
||||
// en parallèle (loadAll), aucun ordre n'est garanti.
|
||||
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
|
||||
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
|
||||
|
||||
function renderModelSwitch(presets, active){
|
||||
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
||||
paintModelSwitch();
|
||||
}
|
||||
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
|
||||
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
|
||||
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
|
||||
async function loadModelSwitchFiles(){
|
||||
try{
|
||||
const r = await jget('/api/models');
|
||||
MS_FILES = Array.isArray(r) ? r : [];
|
||||
}catch(_){ return; }
|
||||
paintModelSwitch();
|
||||
}
|
||||
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
||||
function setLoadedModel(name){
|
||||
MS_MODEL = (name || '').trim();
|
||||
@@ -124,7 +149,20 @@ function paintModelSwitch(){
|
||||
});
|
||||
sel.appendChild(g);
|
||||
}
|
||||
sel.disabled = !MS_PRESETS.length;
|
||||
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
|
||||
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
|
||||
if(MS_FILES.length){
|
||||
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
|
||||
for(const m of MS_FILES){
|
||||
const o = document.createElement('option');
|
||||
o.value = 'm:' + m.value;
|
||||
o.textContent = modelLabel(m.name);
|
||||
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
|
||||
g.appendChild(o);
|
||||
}
|
||||
sel.appendChild(g);
|
||||
}
|
||||
sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
|
||||
sel.title = MS_MODEL
|
||||
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
||||
: 'aucun modèle configuré';
|
||||
|
||||
@@ -12,6 +12,8 @@ let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé)
|
||||
let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune)
|
||||
let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale)
|
||||
let TASK_PROJECTS = []; // projets disponibles (sélecteur « projet visé »)
|
||||
let TASK_SCRIPTS = []; // scripts durables disponibles (tâche « script seul »)
|
||||
let TASK_SCRIPTS_DIR = ''; // chemin du dossier de scripts, pour l'aide affichée
|
||||
let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche)
|
||||
let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche)
|
||||
let tasksPollTimer = null;
|
||||
@@ -35,6 +37,8 @@ function renderTasks(r){
|
||||
const agentOn = !!(r && r.agent);
|
||||
TASK_PRESETS = (r && r.presets) || [];
|
||||
TASK_PROJECTS = (r && r.projects) || [];
|
||||
TASK_SCRIPTS = (r && r.scripts) || [];
|
||||
TASK_SCRIPTS_DIR = (r && r.scripts_dir) || 'le dossier de scripts';
|
||||
TASK_MEM_ON = !r || r.mem_on !== false;
|
||||
TASK_WEB_ON = !r || r.web_on !== false;
|
||||
TASK_RUNNING = (r && r.running_id) || '';
|
||||
@@ -194,6 +198,9 @@ function openTask(id){
|
||||
// stockés en négation) ; sur une nouvelle on suit l'état global de la machine.
|
||||
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
|
||||
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
|
||||
// Type : IA par défaut. Une tâche script n'a ni consigne ni preset utile.
|
||||
setTaskKind(t && t.kind === 'script' ? 'script' : 'agent');
|
||||
fillScriptSelect(t ? (t.script||'') : '');
|
||||
fillPresetSelect(t ? (t.preset||'') : '');
|
||||
// Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une
|
||||
// tâche pour le chantier qu'on a sous les yeux.
|
||||
@@ -284,6 +291,51 @@ function fillTaskProjectSelect(selected){
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
}
|
||||
|
||||
// fillScriptSelect peuple le sélecteur de script avec les fichiers du dossier
|
||||
// protégé (LOKI_HOME/scripts). Dossier vide = on le dit et on laisse le
|
||||
// sélecteur vide : l'enregistrement sera refusé côté serveur, autant l'annoncer.
|
||||
function fillScriptSelect(selected){
|
||||
const sel = document.getElementById('task-script');
|
||||
if(!sel) return;
|
||||
sel.textContent = '';
|
||||
TASK_SCRIPTS.forEach(sc=>{
|
||||
const o = document.createElement('option'); o.value = sc.name;
|
||||
o.textContent = sc.name + (sc.size ? ' · ' + fmtSize(sc.size) : '');
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
const hint = document.getElementById('task-script-hint');
|
||||
if(hint){
|
||||
hint.textContent = TASK_SCRIPTS.length
|
||||
? 'Scripts de ' + TASK_SCRIPTS_DIR + ' — l\'IA peut y écrire les siens.'
|
||||
: 'Aucun script dans ' + TASK_SCRIPTS_DIR + ' : demande à l\'IA d\'y en écrire un, ou dépose-le toi-même.';
|
||||
}
|
||||
}
|
||||
|
||||
// setTaskKind bascule le formulaire entre « consigne IA » et « script seul ».
|
||||
function setTaskKind(kind){
|
||||
const r = document.querySelector('input[name="task-kind"][value="'+kind+'"]');
|
||||
if(r) r.checked = true;
|
||||
taskKindUI(kind);
|
||||
}
|
||||
|
||||
// taskKindUI n'affiche que les champs du type choisi : une tâche script n'a pas
|
||||
// de consigne, et le preset ne la concerne pas (aucun modèle n'est chargé).
|
||||
function taskKindUI(kind){
|
||||
const script = kind === 'script';
|
||||
const pg = document.getElementById('task-prompt-group');
|
||||
const sg = document.getElementById('task-script-group');
|
||||
if(pg) pg.style.display = script ? 'none' : '';
|
||||
if(sg) sg.style.display = script ? '' : 'none';
|
||||
const pr = document.getElementById('task-preset');
|
||||
if(pr) pr.closest('.pe-row').style.display = script ? 'none' : '';
|
||||
}
|
||||
|
||||
function taskKind(){
|
||||
const r = document.querySelector('input[name="task-kind"]:checked');
|
||||
return (r && r.value === 'script') ? 'script' : 'agent';
|
||||
}
|
||||
|
||||
function setTaskFreqMode(mode){
|
||||
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
|
||||
if(r) r.checked = true;
|
||||
@@ -319,6 +371,8 @@ function buildSchedule(){
|
||||
|
||||
async function saveTask(){
|
||||
const name = document.getElementById('task-name').value.trim();
|
||||
const kind = taskKind();
|
||||
const script = (document.getElementById('task-script')||{}).value || '';
|
||||
const prompt = document.getElementById('task-prompt').value.trim();
|
||||
const schedule = buildSchedule();
|
||||
const enabled = document.getElementById('task-enabled').checked;
|
||||
@@ -328,9 +382,13 @@ async function saveTask(){
|
||||
const no_mem = !document.getElementById('task-mem').checked;
|
||||
const no_web = !document.getElementById('task-web').checked;
|
||||
const st = document.getElementById('task-modal-status');
|
||||
if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; }
|
||||
if(!name){ st.textContent = 'nom obligatoire'; st.style.color = 'var(--err)'; return; }
|
||||
if(kind === 'script' ? !script : !prompt){
|
||||
st.textContent = kind === 'script' ? 'choisis un script' : 'consigne obligatoire';
|
||||
st.style.color = 'var(--err)'; return;
|
||||
}
|
||||
if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; }
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, project, tz, no_mem, no_web});
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, kind, script, prompt, schedule, enabled, preset, project, tz, no_mem, no_web});
|
||||
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
|
||||
closeTask();
|
||||
await loadTasks();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 27-push.js — notifications Web Push côté client (voir push.go / sw.js).
|
||||
//
|
||||
// Le SERVEUR pousse une notif à la fin d'un tour et à la fin d'une tâche
|
||||
// planifiée, même app fermée ou téléphone verrouillé. Ici on ne gère que
|
||||
// l'INSCRIPTION : enregistrer le service worker, demander la permission (sur
|
||||
// clic — un geste utilisateur est obligatoire), s'abonner avec la clé publique
|
||||
// VAPID du serveur, et lui transmettre l'abonnement.
|
||||
//
|
||||
// ⚠️ Le service worker est un fichier de l'ORIGINE (/sw.js), pas un appel
|
||||
// /api : il doit venir de la racine pour couvrir toute l'app. La clé VAPID et
|
||||
// l'enregistrement de l'abonnement, eux, passent par jfetch (donc par la clé
|
||||
// de pilotage, et par le canal E2E à distance).
|
||||
|
||||
// pushSupported : le navigateur sait-il faire du Web Push ? (Safari iOS hors
|
||||
// PWA installée, vieux navigateurs → non.)
|
||||
function pushSupported(){
|
||||
return ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window);
|
||||
}
|
||||
|
||||
// La clé VAPID est renvoyée en base64url ; PushManager.subscribe veut un Uint8Array.
|
||||
function urlB64ToUint8Array(base64){
|
||||
const pad = '='.repeat((4 - base64.length % 4) % 4);
|
||||
const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(b64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for(let i=0;i<raw.length;i++) out[i]=raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Enregistre (une seule fois) le service worker et renvoie sa registration.
|
||||
// Scope racine : le worker doit couvrir toute l'app pour recevoir les push.
|
||||
let _swReg = null;
|
||||
async function pushRegisterSW(){
|
||||
if(_swReg) return _swReg;
|
||||
_swReg = await navigator.serviceWorker.register('/sw.js', {scope:'/'});
|
||||
return _swReg;
|
||||
}
|
||||
|
||||
function pushSetStatus(msg){
|
||||
const el = document.getElementById('push-status');
|
||||
if(el) el.textContent = msg || '';
|
||||
}
|
||||
|
||||
// Reflète l'état RÉEL (abonné ou non, permission refusée, non supporté) dans
|
||||
// l'interrupteur et la ligne d'état. Appelé au chargement et après bascule.
|
||||
async function pushRefresh(){
|
||||
const cb = document.getElementById('push-toggle');
|
||||
if(!cb) return;
|
||||
if(!pushSupported()){
|
||||
cb.checked = false; cb.disabled = true;
|
||||
// iOS ne sait faire du Web Push QUE depuis une PWA ajoutée à l'écran d'accueil.
|
||||
if(document.documentElement.getAttribute('data-pwa')!=='1' && /iphone|ipad|ipod/i.test(navigator.userAgent))
|
||||
pushSetStatus("sur iPhone : ajoute d'abord Loki à l'écran d'accueil, puis active depuis l'app installée.");
|
||||
else
|
||||
pushSetStatus('ce navigateur ne sait pas recevoir de notifications push.');
|
||||
return;
|
||||
}
|
||||
// HTTPS (ou localhost) obligatoire : sans contexte sûr, ni service worker ni
|
||||
// push. Le dire ici évite un « échec » incompréhensible au clic.
|
||||
if(!window.isSecureContext){
|
||||
cb.checked = false; cb.disabled = true;
|
||||
pushSetStatus('HTTPS requis (ou localhost) : ouvre Loki en https pour activer les notifications.');
|
||||
return;
|
||||
}
|
||||
if(Notification.permission === 'denied'){
|
||||
cb.checked = false; cb.disabled = false;
|
||||
pushSetStatus('notifications bloquées dans le navigateur — à débloquer dans ses réglages de site.');
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const reg = await pushRegisterSW();
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
cb.checked = !!sub; cb.disabled = false;
|
||||
pushSetStatus(sub ? 'activées sur cet appareil.' : '');
|
||||
}catch(e){ cb.checked=false; pushSetStatus(''); }
|
||||
}
|
||||
|
||||
// togglePush : abonne ou désabonne selon l'état de l'interrupteur.
|
||||
async function togglePush(){
|
||||
const cb = document.getElementById('push-toggle');
|
||||
const want = cb.checked;
|
||||
if(!pushSupported()){ cb.checked=false; await pushRefresh(); return; }
|
||||
try{
|
||||
if(want){
|
||||
// Permission (geste utilisateur = ce clic). Refus → on éteint et on explique.
|
||||
const perm = await Notification.requestPermission();
|
||||
if(perm !== 'granted'){ cb.checked=false; pushSetStatus('permission refusée.'); return; }
|
||||
const reg = await pushRegisterSW();
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if(!sub){
|
||||
const r = await jget('/api/push/key');
|
||||
if(!r || !r.key){ cb.checked=false; pushSetStatus('clé du serveur indisponible.'); return; }
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true, // exigé par Chrome : pas de push silencieux
|
||||
applicationServerKey: urlB64ToUint8Array(r.key)
|
||||
});
|
||||
}
|
||||
const res = await jpost('/api/push/subscribe', sub.toJSON());
|
||||
if(!res || !res.ok){ pushSetStatus("le serveur n'a pas enregistré l'abonnement."); }
|
||||
else pushSetStatus('activées sur cet appareil.');
|
||||
toast('notifications activées');
|
||||
} else {
|
||||
const reg = await pushRegisterSW();
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if(sub){
|
||||
const ep = sub.endpoint;
|
||||
await sub.unsubscribe().catch(()=>{});
|
||||
await jpost('/api/push/unsubscribe', {endpoint:ep}).catch(()=>{});
|
||||
}
|
||||
pushSetStatus('');
|
||||
toast('notifications désactivées');
|
||||
}
|
||||
}catch(e){
|
||||
cb.checked = !want;
|
||||
pushSetStatus('erreur : ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
// Au chargement : enregistre le SW en avance (pour recevoir les push sans même
|
||||
// ouvrir les réglages) et cale l'interrupteur. Silencieux si non supporté.
|
||||
if(pushSupported() && window.isSecureContext){
|
||||
navigator.serviceWorker.register('/sw.js', {scope:'/'}).then(r=>{ _swReg=r; }).catch(()=>{});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', ()=>{ pushRefresh(); });
|
||||
@@ -0,0 +1,240 @@
|
||||
// 28-i18n.js — interface en anglais, par-dessus une source française.
|
||||
//
|
||||
// L'amont (AJEAN) a un vrai système de clés : chaque texte de l'UI porte un
|
||||
// data-i18n et vit dans une table FR/EN. Traduire Loki de la même façon
|
||||
// demanderait de réécrire tous ses écrans d'un coup, avec le risque d'en casser
|
||||
// un pour une clé oubliée. On prend l'autre chemin, additif : la source RESTE le
|
||||
// français, et passer en anglais applique un DICTIONNAIRE sur le texte affiché.
|
||||
//
|
||||
// Conséquences, assumées :
|
||||
// - une chaîne absente du dictionnaire reste en français (dégradation douce,
|
||||
// jamais de « settings.memory.title » à l'écran) ;
|
||||
// - le dictionnaire s'enrichit sans toucher au reste de l'interface ;
|
||||
// - la correspondance est EXACTE (texte complet d'un nœud, une fois les
|
||||
// espaces retirés) : aucune substitution partielle, donc pas de mot traduit
|
||||
// au milieu d'une phrase qui ne l'est pas.
|
||||
//
|
||||
// Ce qui n'est JAMAIS traduit : le fil de discussion (#chat) — c'est ton
|
||||
// contenu et celui du modèle —, le code (pre/code), les zones de saisie, et
|
||||
// tout ce qui porte data-no-i18n.
|
||||
|
||||
const LANG_KEY = 'loki-lang';
|
||||
function uiLang(){ try{ return localStorage.getItem(LANG_KEY) || 'fr'; }catch(e){ return 'fr'; } }
|
||||
|
||||
// Dictionnaire français → anglais. Couvre la « coque » : navigation des
|
||||
// réglages, intitulés de sections, libellés de lignes, boutons, interrupteurs.
|
||||
// Les longues bulles d'aide restent en français pour l'instant — elles sont
|
||||
// nombreuses et leur traduction n'apporte rien tant que la coque ne l'est pas.
|
||||
const I18N_EN = {
|
||||
// Navigation des réglages
|
||||
'Paramètres': 'Settings',
|
||||
'Intelligence artificielle': 'Artificial intelligence',
|
||||
'Moteur': 'Engine',
|
||||
'Application': 'Application',
|
||||
'Projets': 'Projects',
|
||||
'Config active': 'Active config',
|
||||
'Presets': 'Presets',
|
||||
'Mode agent': 'Agent mode',
|
||||
'Tâches planifiées': 'Scheduled tasks',
|
||||
'System prompt': 'System prompt',
|
||||
'Moteur llama.cpp': 'llama.cpp engine',
|
||||
'Journal du moteur': 'Engine log',
|
||||
'Dictée': 'Dictation',
|
||||
'Identité': 'Identity',
|
||||
'Apparence': 'Appearance',
|
||||
'Accès OpenAI': 'OpenAI access',
|
||||
'Actions': 'Actions',
|
||||
// Sections
|
||||
'Mémoire': 'Memory',
|
||||
'Accès internet': 'Internet access',
|
||||
'Contrôle du navigateur': 'Browser control',
|
||||
'Serveurs MCP': 'MCP servers',
|
||||
'Service du moteur': 'Engine service',
|
||||
'Modèle': 'Model',
|
||||
'Reconnaissance': 'Recognition',
|
||||
'Affichage': 'Display',
|
||||
'Endpoint compatible OpenAI': 'OpenAI-compatible endpoint',
|
||||
'Clé API': 'API key',
|
||||
'Connexion': 'Connection',
|
||||
'Format': 'Format',
|
||||
'Portée': 'Scope',
|
||||
'Contenu': 'Content',
|
||||
'Nom': 'Name',
|
||||
'Type': 'Type',
|
||||
'Consigne': 'Instruction',
|
||||
'Script': 'Script',
|
||||
'Preset': 'Preset',
|
||||
'Fréquence': 'Frequency',
|
||||
'Accès': 'Access',
|
||||
'Cartes graphiques': 'Graphics cards',
|
||||
'Réglages': 'Settings',
|
||||
'Échantillonnage': 'Sampling',
|
||||
'Sauvegarde chiffrée': 'Encrypted backup',
|
||||
'Ton avatar': 'Your avatar',
|
||||
'Avatar de Loki': "Loki's avatar",
|
||||
'À exécuter': 'To run',
|
||||
'Modèle utilisé': 'Model used',
|
||||
'Projet visé': 'Target project',
|
||||
'Toutes les': 'Every',
|
||||
'Accès à la mémoire': 'Memory access',
|
||||
'Accès au web': 'Web access',
|
||||
'Tâche active': 'Task enabled',
|
||||
'Fichier': 'File',
|
||||
'Destination': 'Destination',
|
||||
'Contexte': 'Context',
|
||||
'Vision': 'Vision',
|
||||
'Vérifier': 'Check',
|
||||
// Interrupteurs
|
||||
'activer le mode agent': 'enable agent mode',
|
||||
"me prévenir quand c'est prêt": 'notify me when it is ready',
|
||||
'chiffrer la mémoire sur le disque': 'encrypt memory on disk',
|
||||
"donner internet à l'IA": 'give the AI internet access',
|
||||
"laisser l'IA piloter un navigateur": 'let the AI drive a browser',
|
||||
'compactage automatique du contexte': 'automatic context compaction',
|
||||
'suspendre toutes les tâches': 'suspend all tasks',
|
||||
'masquer le raisonnement': 'hide reasoning',
|
||||
"masquer les appels d'outils": 'hide tool calls',
|
||||
'garder les bulles repliées': 'keep bubbles collapsed',
|
||||
'masquer la vitesse de génération': 'hide generation speed',
|
||||
'mode': 'mode',
|
||||
// Boutons
|
||||
'Libérer la VRAM': 'Free VRAM',
|
||||
'libérer la VRAM': 'free VRAM',
|
||||
'Chat': 'Chat',
|
||||
'Code': 'Code',
|
||||
'compacter': 'compact',
|
||||
'fermer': 'close',
|
||||
'relancer': 'restart',
|
||||
'Tester la connexion': 'Test connection',
|
||||
'supprimer': 'delete',
|
||||
'annuler': 'cancel',
|
||||
'Annuler': 'Cancel',
|
||||
'Confirmer': 'Confirm',
|
||||
'enregistrer': 'save',
|
||||
'Enregistrer': 'Save',
|
||||
'+ ajouter un projet': '+ add a project',
|
||||
'+ ajouter une tâche': '+ add a task',
|
||||
'+ ajouter un serveur': '+ add a server',
|
||||
'+ ajouter un point': '+ add an entry',
|
||||
'+ ajouter': '+ add',
|
||||
'exporter': 'export',
|
||||
'importer': 'import',
|
||||
'retirer': 'remove',
|
||||
'catalogue': 'catalog',
|
||||
'renommer': 'rename',
|
||||
'déplacer': 'move',
|
||||
'vérifier les mises à jour': 'check for updates',
|
||||
'↥ Vérifier les mises à jour': '↥ Check for updates',
|
||||
'mettre à jour le moteur': 'update the engine',
|
||||
"revenir au moteur de l'image": 'go back to the image engine',
|
||||
'démarrer': 'start',
|
||||
'redémarrer': 'restart',
|
||||
'arrêter': 'stop',
|
||||
'Télécharger ce modèle': 'Download this model',
|
||||
'Annuler le téléchargement': 'Cancel download',
|
||||
'Retirer le jeton': 'Remove token',
|
||||
'Tester le micro (3 s)': 'Test the mic (3 s)',
|
||||
'tester maintenant': 'test now',
|
||||
'afficher': 'show',
|
||||
'générer': 'generate',
|
||||
'définir…': 'set…',
|
||||
'⤓ exporter…': '⤓ export…',
|
||||
'⤓ télécharger': '⤓ download',
|
||||
'bench': 'bench',
|
||||
'Installer & compiler': 'Install & build',
|
||||
'voir plus': 'show more',
|
||||
'voir moins': 'show less',
|
||||
'copier': 'copy',
|
||||
'copié': 'copied',
|
||||
'réponse': 'response',
|
||||
'modifications': 'changes',
|
||||
'écriture en cours': 'writing',
|
||||
'exécution en cours…': 'running…',
|
||||
// Divers visibles
|
||||
'Nouvelle discussion': 'New chat',
|
||||
'Discussions': 'Chats',
|
||||
'Langue': 'Language',
|
||||
'Français': 'French',
|
||||
'Anglais': 'English',
|
||||
};
|
||||
|
||||
// Sélecteurs dont le CONTENU n'est jamais traduit : ton texte et celui du
|
||||
// modèle, le code, les champs de saisie.
|
||||
const I18N_SKIP = '#chat, pre, code, textarea, input, [data-no-i18n]';
|
||||
|
||||
function i18nText(fr){
|
||||
const key = (fr || '').trim();
|
||||
if (!key) return null;
|
||||
const en = I18N_EN[key];
|
||||
if (en === undefined) return null;
|
||||
// On rend la traduction en conservant l'espacement d'origine autour du texte
|
||||
// (un « ' ' + label » dans le HTML garde son espace).
|
||||
const lead = fr.slice(0, fr.length - fr.trimStart().length);
|
||||
const tail = fr.slice(fr.trimEnd().length);
|
||||
return lead + en + tail;
|
||||
}
|
||||
|
||||
// Traduit les nœuds texte et quelques attributs sous `root`.
|
||||
function i18nApply(root){
|
||||
if (uiLang() !== 'en' || !root) return;
|
||||
const el = root.nodeType === 1 ? root : root.parentElement;
|
||||
if (el && el.closest && el.closest(I18N_SKIP)) return;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(n){
|
||||
if (!n.nodeValue || !n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
|
||||
if (n.parentElement && n.parentElement.closest(I18N_SKIP)) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
const todo = [];
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) todo.push(n);
|
||||
for (const n of todo){
|
||||
const tr = i18nText(n.nodeValue);
|
||||
if (tr !== null) n.nodeValue = tr;
|
||||
}
|
||||
// Attributs visibles (infobulles, champs vides, accessibilité).
|
||||
const scope = root.nodeType === 1 ? root : document.body;
|
||||
const all = scope.querySelectorAll ? [scope, ...scope.querySelectorAll('[placeholder],[title],[aria-label]')] : [];
|
||||
for (const e of all){
|
||||
if (!e.getAttribute) continue;
|
||||
for (const attr of ['placeholder', 'title', 'aria-label']){
|
||||
const v = e.getAttribute(attr);
|
||||
if (!v) continue;
|
||||
const tr = i18nText(v);
|
||||
if (tr !== null) e.setAttribute(attr, tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les panneaux sont rendus en JS bien après le chargement : un observateur
|
||||
// traduit ce qui apparaît. Il ignore #chat, donc le fil ne passe jamais dessous.
|
||||
function i18nWatch(){
|
||||
if (uiLang() !== 'en') return;
|
||||
const obs = new MutationObserver(muts => {
|
||||
for (const m of muts){
|
||||
for (const n of m.addedNodes){
|
||||
if (n.nodeType === 1 || n.nodeType === 3) i18nApply(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe(document.body, {childList: true, subtree: true});
|
||||
}
|
||||
|
||||
// setUiLang : bascule la langue. Vers l'anglais, on applique tout de suite ;
|
||||
// vers le français, on RECHARGE — le texte français d'origine a été remplacé
|
||||
// dans le DOM, le recharger est le moyen le plus sûr de le retrouver intact.
|
||||
function setUiLang(lang){
|
||||
const next = lang === 'en' ? 'en' : 'fr';
|
||||
const prev = uiLang();
|
||||
try{ localStorage.setItem(LANG_KEY, next); }catch(e){}
|
||||
if (next === prev) return;
|
||||
if (next === 'en'){ i18nApply(document.body); i18nWatch(); }
|
||||
else location.reload();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sel = document.getElementById('ui-lang');
|
||||
if (sel) sel.value = uiLang();
|
||||
i18nApply(document.body);
|
||||
i18nWatch();
|
||||
});
|
||||
@@ -443,7 +443,28 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
|
||||
.diff-count .a{color:var(--add)}
|
||||
.diff-count .d{color:var(--del)}
|
||||
/* Bandeau « début de la discussion masqué » : le serveur ne rejoue que la fin
|
||||
d'un très long fil (replayTailMax). Discret, en tête du fil, avec le bouton
|
||||
qui va chercher le reste. */
|
||||
.replay-banner{margin:4px auto 10px;padding:6px 10px;font-size:11px;color:var(--dim);background:var(--panel);border:1px solid var(--border);border-radius:3px;text-align:center;max-width:680px}
|
||||
.replay-banner .replay-more{padding:2px 8px;font-size:11px;background:none;border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer}
|
||||
.replay-banner .replay-more:hover{color:var(--accent);border-color:var(--accent)}
|
||||
.replay-banner .replay-more:disabled{cursor:default;opacity:.6}
|
||||
.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
|
||||
/* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre
|
||||
ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre
|
||||
scrolle (max-height), donc la barre est portée par le bloc-parent NON
|
||||
scrollant (.tool-block) : elle reste au coin quoi qu'on scrolle. */
|
||||
.msg.tool .tool-block{position:relative}
|
||||
.msg.tool .tool-block .tool-actions{position:absolute;bottom:7px;right:7px;display:flex;gap:6px;align-items:center;z-index:1}
|
||||
.msg.tool .tool-actions .copybtn{position:static;bottom:auto;right:auto}
|
||||
.msg.tool .tool-more{padding:3px 8px;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;opacity:.5;transition:opacity .15s,color .15s,border-color .15s}
|
||||
.msg.tool .tool-block:hover .tool-actions .tool-more,.msg.tool .tool-block:hover .tool-actions .copybtn{opacity:1}
|
||||
.msg.tool .tool-more:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.msg.tool .tool-more:disabled{cursor:default}
|
||||
/* Déplié : on lève le plafond de hauteur pour voir VRAIMENT plus, au lieu de
|
||||
rester enfermé dans une fenêtre de 280 px qu'il faut scroller. */
|
||||
.msg.tool pre.expanded{max-height:none}
|
||||
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
|
||||
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
|
||||
.msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Loki service worker — UNIQUEMENT les notifications Web Push.
|
||||
//
|
||||
// ⚠️ VOLONTAIREMENT sans cache ni handler `fetch` : l'interface est un seul
|
||||
// index.html régénéré à chaque build (assemble-ui) et servi depuis le binaire.
|
||||
// Un service worker qui interposerait une réponse en cache servirait une UI
|
||||
// périmée après une mise à jour de l'image, sans moyen simple de s'en rendre
|
||||
// compte. Ce worker ne fait donc QUE recevoir les push du serveur et afficher
|
||||
// la notification.
|
||||
//
|
||||
// Le SERVEUR (push.go) pousse à la fin d'un tour utilisateur et à la fin d'une
|
||||
// tâche planifiée, même app fermée ou téléphone verrouillé — c'est tout
|
||||
// l'intérêt par rapport à une notification côté page, que l'onglet caché ne
|
||||
// peut plus produire (il relâche son flux SSE).
|
||||
|
||||
self.addEventListener('install', function(){ self.skipWaiting(); });
|
||||
self.addEventListener('activate', function(e){ e.waitUntil(self.clients.claim()); });
|
||||
|
||||
self.addEventListener('push', function(e){
|
||||
var data = { title: 'Loki', body: 'Réponse prête' };
|
||||
try { if (e.data) data = Object.assign(data, e.data.json()); } catch (_){}
|
||||
e.waitUntil(self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
// tag + renotify : une nouvelle réponse REMPLACE l'ancienne notif (pas
|
||||
// d'empilement), mais re-sonne/vibre pour signaler qu'elle est fraîche.
|
||||
tag: data.tag || 'loki-turn',
|
||||
renotify: true,
|
||||
// Icône = le carré « loki », en data-URI : aucun fichier externe à servir.
|
||||
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>"
|
||||
}));
|
||||
});
|
||||
|
||||
// Clic sur la notif : ramène l'onglet Loki au premier plan s'il est déjà ouvert,
|
||||
// sinon en ouvre un. `includeUncontrolled` : les onglets ouverts AVANT que ce
|
||||
// worker prenne le contrôle comptent aussi.
|
||||
self.addEventListener('notificationclick', function(e){
|
||||
e.notification.close();
|
||||
e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(cl){
|
||||
for (var i = 0; i < cl.length; i++){ if ('focus' in cl[i]) return cl[i].focus(); }
|
||||
if (self.clients.openWindow) return self.clients.openWindow('/');
|
||||
}));
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -698,6 +700,206 @@ func localIP() string {
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
// --- Chiffrement de la mémoire (mem_crypto/mem_vault/mem_migrate) -----------
|
||||
|
||||
// handleMemHealth : état du chiffrement (actif ? verrouillé ? tout chiffré ?),
|
||||
// nombre de pages et snapshots locaux.
|
||||
func handleMemHealth(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, memHealth())
|
||||
}
|
||||
|
||||
// handleMemEncrypt active le chiffrement. Le corps porte le mot de passe
|
||||
// mémoire. La réponse contient la CLÉ DE RÉCUPÉRATION, à afficher UNE fois :
|
||||
// elle n'est jamais reconsultable ensuite.
|
||||
func handleMemEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
rec, err := EnableMemEncryption(req.Password)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "recovery": rec, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleMemDecrypt remet la mémoire en clair (exige d'être déverrouillé).
|
||||
func handleMemDecrypt(w http.ResponseWriter, r *http.Request) {
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleMemUnlock déverrouille la mémoire : accepte le mot de passe OU la clé
|
||||
// de récupération (on tente les deux formes). Charge la DEK en RAM.
|
||||
func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if strings.TrimSpace(req.Secret) == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "secret vide"})
|
||||
return
|
||||
}
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun coffre (mémoire non chiffrée ?)"})
|
||||
return
|
||||
}
|
||||
dek, kind, err := v.unlockWith(req.Secret)
|
||||
if err != nil {
|
||||
// La clé de récupération se saisit avec tirets/espaces : on retente normalisée.
|
||||
if d2, k2, e2 := v.unlockWith(normalizeRecovery(req.Secret)); e2 == nil {
|
||||
dek, kind, err = d2, k2, e2
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
sendJSON(w, 401, map[string]any{"ok": false, "error": "secret incorrect"})
|
||||
return
|
||||
}
|
||||
setMemDEK(dek)
|
||||
resumeMemMigration() // si une migration attendait le déverrouillage
|
||||
reloadEncryptedStores() // recharge la discussion chiffrée en RAM
|
||||
// Migration douce : chiffre ce qui serait resté en clair (discussions,
|
||||
// blocs archivés, trackers). Idempotent — n'encode que ce qui ne l'est pas.
|
||||
if memEncActive() && memUnlocked() {
|
||||
_ = reencryptChatStores()
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "kind": kind, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleMemLock purge la DEK de la RAM (reverrouille sans redémarrer).
|
||||
func handleMemLock(w http.ResponseWriter, r *http.Request) {
|
||||
clearMemDEK()
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleMemSnapshots liste les snapshots locaux de la mémoire (GET) ou en
|
||||
// restaure un (POST {id}). Un snapshot est pris avant chaque opération qui
|
||||
// touche à tout (chiffrement, déchiffrement) : c'est le filet.
|
||||
func handleMemSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := restoreSnapshot(req.ID); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "snapshots": listSnapshots()})
|
||||
}
|
||||
|
||||
// --- Sauvegarde chiffrée, en FICHIER (pas de relais) ------------------------
|
||||
//
|
||||
// L'amont sauvegarde vers son relais (ajean.link). Ici, le même paquet chiffré
|
||||
// se télécharge et se réimporte À LA MAIN : rien ne quitte la machine sans que
|
||||
// tu l'aies demandé, et un conteneur se remonte ailleurs avec un seul fichier.
|
||||
// Le corps est chiffré avec la DEK de la mémoire ; l'entête porte le coffre, si
|
||||
// bien que la restauration n'a besoin que de la clé de pilotage (ou de la clé
|
||||
// de récupération) — aucun état de l'ancienne machine.
|
||||
|
||||
// handleBackupExport (GET) télécharge le paquet chiffré {mémoire, presets,
|
||||
// réglages}. Exige une mémoire chiffrée ET déverrouillée : sans DEK, il n'y a
|
||||
// pas de quoi sceller le paquet, et l'envoyer en clair serait un piège.
|
||||
func handleBackupExport(w http.ResponseWriter, r *http.Request) {
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun coffre : active d'abord le chiffrement de la mémoire"})
|
||||
return
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "mémoire verrouillée"})
|
||||
return
|
||||
}
|
||||
tarData, err := buildBundleTar()
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
blob, err := buildBackupBlob(v, dek, tarData)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := "loki-backup-" + time.Now().Format("20060102-150405") + ".lkbk"
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(blob)))
|
||||
_, _ = w.Write(blob)
|
||||
}
|
||||
|
||||
// handleBackupImport (POST {data: base64, secret}) restaure un paquet exporté.
|
||||
// Un snapshot de la mémoire est pris AVANT d'écrire quoi que ce soit.
|
||||
func handleBackupImport(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Data string `json:"data"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// "data:application/octet-stream;base64,…" : on ne garde que la charge utile.
|
||||
data := req.Data
|
||||
if i := strings.Index(data, ","); i >= 0 && strings.HasPrefix(data, "data:") {
|
||||
data = data[i+1:]
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data))
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier illisible (base64 attendu)"})
|
||||
return
|
||||
}
|
||||
tarData, err := openBackupBlob(blob, req.Secret)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := restoreBundleTar(tarData); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleComputer pilote le pilotage de navigateur (outils browser_*).
|
||||
//
|
||||
// GET → {enabled, browser, browser_ok, vision}
|
||||
// POST {on} → active/désactive (et ferme le navigateur quand on coupe).
|
||||
func handleComputer(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := setComputerUseEnabled(req.On); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Couper l'interrupteur ferme le navigateur : le laisser tourner
|
||||
// garderait un Chromium (et son profil temporaire) sur les bras alors
|
||||
// que plus aucun outil ne peut s'en servir.
|
||||
if !req.On {
|
||||
cdpShutdown()
|
||||
}
|
||||
}
|
||||
bin := chromePath()
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"enabled": computerUseEnabled(),
|
||||
"browser": bin,
|
||||
"browser_ok": bin != "",
|
||||
"vision": visionEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
func handleInternet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
@@ -988,6 +1190,38 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleModelUse charge un MODÈLE précis (fichier .gguf) sans passer par un
|
||||
// preset : seul MODEL change dans la configuration active — contexte, NGL et
|
||||
// échantillonnage sont conservés — et le service redémarre en arrière-plan,
|
||||
// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
|
||||
// utilisable même sans avoir créé de preset.
|
||||
func handleModelUse(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Value) == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle manquant"})
|
||||
return
|
||||
}
|
||||
// Le fichier doit exister AVANT d'écrire la config : une faute de frappe ne
|
||||
// doit pas laisser un moteur qui boucle sur un modèle introuvable.
|
||||
if _, err := resolveServeModelPath(req.Value); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := SetConfigKey("MODEL", req.Value); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s MODEL <- %s\n", green("[ok]"), req.Value)
|
||||
go func() {
|
||||
if err := serviceAction("restart"); err != nil {
|
||||
fmt.Println(red("[err] redémarrage : " + err.Error()))
|
||||
}
|
||||
}()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
N int `json:"n"`
|
||||
@@ -1115,6 +1349,12 @@ type chatReq struct {
|
||||
// vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct).
|
||||
Message string `json:"message"`
|
||||
From int `json:"from"`
|
||||
// Full = rejouer TOUT le journal de la discussion. Par défaut le replay est
|
||||
// BORNÉ à sa fin (voir replayTailMax) : une discussion de plusieurs centaines
|
||||
// de tours mettait des secondes à s'afficher et laissait autant de bulles
|
||||
// dans le DOM. Le bouton « charger le début » de l'interface se réabonne
|
||||
// avec full=true.
|
||||
Full bool `json:"full"`
|
||||
// Files = chemins relatifs des fichiers déposés juste avant par
|
||||
// /api/chat/upload ("uploads/rapport.pdf"). Ils sont annoncés au modèle en
|
||||
// tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et
|
||||
|
||||
@@ -84,7 +84,7 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
|
||||
// goroutine détachée — fermer le navigateur n'arrête donc plus rien. Partagé par
|
||||
// handleChat (clair) et handleE2EChat (chiffré).
|
||||
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
|
||||
conv.Subscribe(ctx, body.From, emit)
|
||||
conv.Subscribe(ctx, body.From, body.Full, emit)
|
||||
}
|
||||
|
||||
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse
|
||||
@@ -202,6 +202,35 @@ func handleChatState(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, conv.state())
|
||||
}
|
||||
|
||||
// handleToolResult renvoie le résultat COMPLET d'un appel d'outil, chargé à la
|
||||
// demande par le bouton « voir plus » de la bulle (le flux ne transporte qu'un
|
||||
// aperçu). Le résultat complet vit dans la vue « modèle » (conv.Messages), repéré
|
||||
// par son tool_call_id. Absent (compacté, archivé, discussion changée) → 404, et
|
||||
// l'UI garde son aperçu plutôt que d'afficher une erreur.
|
||||
func handleToolResult(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
sendJSON(w, 400, map[string]any{"error": "id manquant"})
|
||||
return
|
||||
}
|
||||
conv.mu.Lock()
|
||||
found, ok := "", false
|
||||
for _, m := range conv.Messages {
|
||||
if m.Role == "tool" && m.ToolCallID == id {
|
||||
if s, isStr := m.Content.(string); isStr {
|
||||
found, ok = s, true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
conv.mu.Unlock()
|
||||
if !ok {
|
||||
sendJSON(w, 404, map[string]any{"error": "résultat non disponible"})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"result": found})
|
||||
}
|
||||
|
||||
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
var body chatReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// web_push.go — endpoints d'inscription aux notifications Web Push (voir push.go).
|
||||
// Tous passent par /api/* (donc par jfetch, et par le canal E2E à distance)
|
||||
// en simple req/resp : le service worker et le manifest, eux, sont servis en clair
|
||||
// à la racine (web_server.go), car un service worker doit venir de l'origine même.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// pushEndpointHost extrait l'hôte d'un endpoint de push pour les logs (ex.
|
||||
// web.push.apple.com) sans divulguer le jeton complet.
|
||||
func pushEndpointHost(endpoint string) string {
|
||||
if u, err := url.Parse(endpoint); err == nil && u.Host != "" {
|
||||
return u.Host
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// handlePushKey (GET) : remet la clé publique VAPID dont l'UI a besoin pour
|
||||
// s'abonner (PushManager.subscribe applicationServerKey). Générée à la volée au
|
||||
// premier appel, puis stable.
|
||||
func handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||
_, pub, err := vapidKeys()
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "key": pub})
|
||||
}
|
||||
|
||||
// handlePushSubscribe (POST) : enregistre l'abonnement PushSubscription du
|
||||
// navigateur. Corps = l'objet renvoyé par subscription.toJSON() côté JS.
|
||||
func handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
var s pushSub
|
||||
if err := json.NewDecoder(r.Body).Decode(&s); err != nil || s.Endpoint == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "abonnement invalide"})
|
||||
return
|
||||
}
|
||||
if err := addSub(s); err != nil {
|
||||
fmt.Printf("[push] abonnement REFUSÉ (stockage) : %v\n", err)
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
fmt.Printf("[push] abonnement enregistré (%s) — %d au total\n", pushEndpointHost(s.Endpoint), len(loadSubs()))
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handlePushUnsubscribe (POST {endpoint}) : retire un abonnement (l'utilisateur a
|
||||
// coupé les notifs dans les réglages).
|
||||
func handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
removeSub(body.Endpoint)
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
//go:generate go run ../../tools/assemble-ui ui
|
||||
//go:embed ui/index.html ui/marked.min.js ui/fonts/*.woff2
|
||||
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
|
||||
var uiFS embed.FS
|
||||
|
||||
// cmdWeb starts the HTTP server on the given port (default 8090).
|
||||
@@ -122,6 +122,10 @@ func newWebMux() *http.ServeMux {
|
||||
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
|
||||
// plus rien du tout.
|
||||
lcRestoreOnce.Do(lcRestore)
|
||||
// Chiffrement de la mémoire : une migration interrompue (coupure de courant
|
||||
// en plein chiffrement) reprend ici, si la clé est disponible. Sans clé, elle
|
||||
// attend le déverrouillage (voir handleMemUnlock). Idempotent.
|
||||
resumeMemMigration()
|
||||
// Planificateur des tâches : une goroutine, un tic par minute, dans le process
|
||||
// qui détient la conversation et le modèle (idempotent, cf. sync.Once).
|
||||
StartTaskScheduler()
|
||||
@@ -141,6 +145,23 @@ func newWebMux() *http.ServeMux {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write(b)
|
||||
})
|
||||
// Service worker + manifeste des notifications Web Push (voir push.go / sw.js).
|
||||
// PUBLICS (aucun secret) et servis en clair à la RACINE : un service worker
|
||||
// doit venir de l'origine même, et son scope est celui de son URL. no-store
|
||||
// sur le worker pour qu'une mise à jour de l'image soit toujours reprise.
|
||||
mux.HandleFunc("/sw.js", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := uiFS.ReadFile("ui/sw.js")
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||||
w.Header().Set("Service-Worker-Allowed", "/")
|
||||
w.Write(b)
|
||||
})
|
||||
mux.HandleFunc("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := uiFS.ReadFile("ui/manifest.webmanifest")
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(b)
|
||||
})
|
||||
// Polices embarquées (Inter + JetBrains Mono, sous-ensemble latin). Servies
|
||||
// par Loki et non par Google Fonts : une instance locale ou derrière un
|
||||
// réseau fermé doit s'afficher correctement sans appeler un tiers, et sans
|
||||
@@ -191,6 +212,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/models", handleModels)
|
||||
api("/api/models/delete", handleModelDelete)
|
||||
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
|
||||
api("/api/models/use", handleModelUse) // charger CE .gguf (sélecteur de l'en-tête)
|
||||
api("/api/models/download", handleModelDownload)
|
||||
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
||||
api("/api/models/download/status", handleModelDownloadStatus)
|
||||
@@ -244,6 +266,15 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/node/target", handleNodeTarget) // choisit la machine cible de l'agent
|
||||
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
|
||||
api("/api/internet", handleInternet)
|
||||
api("/api/computer", handleComputer) // pilotage de navigateur (outils browser_*)
|
||||
api("/api/mem/health", handleMemHealth) // état chiffrement/verrou/pages/snapshots
|
||||
api("/api/mem/encrypt", handleMemEncrypt) // active le chiffrement (renvoie la clé de récupération)
|
||||
api("/api/mem/decrypt", handleMemDecrypt) // remet la mémoire en clair
|
||||
api("/api/mem/unlock", handleMemUnlock) // déverrouille (mot de passe ou clé de récupération)
|
||||
api("/api/mem/lock", handleMemLock) // reverrouille (purge la DEK de la RAM)
|
||||
api("/api/mem/snapshots", handleMemSnapshots) // liste + restauration des snapshots locaux
|
||||
api("/api/backup/export", handleBackupExport) // télécharge le paquet chiffré (mémoire+presets+réglages)
|
||||
api("/api/backup/import", handleBackupImport) // restaure un paquet exporté
|
||||
api("/api/mcp", handleMCP)
|
||||
api("/api/mcp/catalog", handleMCPCatalog)
|
||||
api("/api/mcp/save", handleMCPSave)
|
||||
@@ -261,6 +292,10 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/tasks/toggle", handleTaskToggle)
|
||||
api("/api/tasks/pause", handleTasksPause)
|
||||
api("/api/tasks/run", handleTaskRun)
|
||||
api("/api/tasks/stop", handleTaskStop) // arrête la tâche en cours (script : via son registre)
|
||||
api("/api/push/key", handlePushKey) // clé publique VAPID (pour s'abonner)
|
||||
api("/api/push/subscribe", handlePushSubscribe) // enregistre un abonnement du navigateur
|
||||
api("/api/push/unsubscribe", handlePushUnsubscribe) // retire un abonnement
|
||||
api("/api/reasoning-effort", handleReasoningEffort) // intensité réglable depuis la barre de saisie
|
||||
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
||||
api("/api/prefs", handleWebPrefs)
|
||||
@@ -302,12 +337,13 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/conversations/switch", handleConvSwitch)
|
||||
api("/api/conversations/rename", handleConvRename)
|
||||
api("/api/conversations/delete", handleConvDelete)
|
||||
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
|
||||
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
|
||||
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
||||
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
||||
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
|
||||
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
|
||||
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||
api("/api/chat/tool-result", handleToolResult) // résultat complet d'un outil, chargé à la demande (bouton « voir plus »)
|
||||
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
||||
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@ func handleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
conv.mu.Lock()
|
||||
runningID := conv.runningTaskID
|
||||
conv.mu.Unlock()
|
||||
// Une tâche script tourne HORS du verrou de génération (aucune inférence) :
|
||||
// conv ne la voit pas, on complète donc avec le registre des scripts en cours
|
||||
// (tasks_script.go), sinon l'UI l'affiche à l'arrêt pendant qu'elle tourne.
|
||||
if runningID == "" {
|
||||
if sid, _ := scriptRunningAny(); sid != "" {
|
||||
runningID = sid
|
||||
}
|
||||
}
|
||||
// Presets (id + nom + actif) pour peupler le sélecteur du formulaire de tâche.
|
||||
presets := []map[string]any{}
|
||||
if list, err := ListPresets(); err == nil {
|
||||
@@ -28,13 +36,20 @@ func handleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
presets = append(presets, map[string]any{"id": p.ID, "name": p.Name, "active": p.Active})
|
||||
}
|
||||
}
|
||||
// Scripts durables disponibles, pour le sélecteur d'une tâche « script seul ».
|
||||
scripts, _ := listScripts()
|
||||
if scripts == nil {
|
||||
scripts = []scriptInfo{}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"tasks": tasks,
|
||||
"paused": tasksPaused(),
|
||||
"agent": agentEnabled(),
|
||||
"running_id": runningID,
|
||||
"presets": presets,
|
||||
"ok": true,
|
||||
"tasks": tasks,
|
||||
"scripts": scripts,
|
||||
"scripts_dir": scriptsDir(),
|
||||
"paused": tasksPaused(),
|
||||
"agent": agentEnabled(),
|
||||
"running_id": runningID,
|
||||
"presets": presets,
|
||||
// Projets, pour le sélecteur « projet visé » du formulaire de tâche.
|
||||
"projects": projectDTOs(),
|
||||
// État global mémoire/web, pour proposer des défauts cohérents à la création.
|
||||
@@ -54,6 +69,8 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
|
||||
TZ string `json:"tz"`
|
||||
Preset string `json:"preset"`
|
||||
Project string `json:"project"`
|
||||
Kind string `json:"kind"`
|
||||
Script string `json:"script"`
|
||||
NoMem bool `json:"no_mem"`
|
||||
NoWeb bool `json:"no_web"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -65,8 +82,26 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Prompt = strings.TrimSpace(req.Prompt)
|
||||
req.Schedule = strings.TrimSpace(req.Schedule)
|
||||
if req.Name == "" || req.Prompt == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom et consigne obligatoires"})
|
||||
req.Kind = strings.TrimSpace(req.Kind)
|
||||
req.Script = strings.TrimSpace(req.Script)
|
||||
if req.Name == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom obligatoire"})
|
||||
return
|
||||
}
|
||||
// Une tâche script exige un script EXISTANT (le vérifier ici évite une tâche
|
||||
// planifiée qui échouera silencieusement au premier tic) ; une tâche IA exige
|
||||
// une consigne.
|
||||
if req.Kind == "script" {
|
||||
if req.Script == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "script obligatoire pour une tâche script"})
|
||||
return
|
||||
}
|
||||
if err := scriptExists(req.Script); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else if req.Prompt == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "consigne obligatoire"})
|
||||
return
|
||||
}
|
||||
if err := validateSchedule(req.Schedule, req.TZ); err != nil {
|
||||
@@ -87,6 +122,7 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
t.Name, t.Prompt, t.Enabled = req.Name, req.Prompt, req.Enabled
|
||||
t.TZ, t.Preset = req.TZ, req.Preset
|
||||
t.Kind, t.Script = req.Kind, req.Script
|
||||
// Projet visé : vide = le projet actif au moment de l'enregistrement. Une tâche
|
||||
// créée depuis un projet appartient à ce projet — c'est là que sa mémoire et
|
||||
// ses trackers l'attendent quand elle tournera (voir runTask).
|
||||
@@ -162,6 +198,22 @@ func handleTasksPause(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "paused": tasksPaused()})
|
||||
}
|
||||
|
||||
// handleTaskStop arrête la tâche en cours. Une tâche SCRIPT ne passe pas par le
|
||||
// verrou de génération : on l'annule via son registre (tasks_script.go). Sinon
|
||||
// (tâche IA), on retombe sur conv.Stop, qui annule la génération autonome.
|
||||
func handleTaskStop(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.ID != "" && scriptRunStop(req.ID) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
conv.Stop()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleTaskRun lance une tâche MAINTENANT (bouton « tester »). Exécution
|
||||
// détachée : on rend la main tout de suite et on rafraîchit l'état à la fin.
|
||||
// 409 si une génération est déjà en cours.
|
||||
@@ -175,6 +227,14 @@ func handleTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"})
|
||||
return
|
||||
}
|
||||
// Tâche script : aucune inférence, donc ni verrou de génération ni moteur
|
||||
// chargé à attendre — la refuser parce que le modèle répond ailleurs n'aurait
|
||||
// aucun sens.
|
||||
if t.Kind == "script" {
|
||||
go runTask(t)
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
// Vérifie la disponibilité AVANT de détacher, pour pouvoir répondre 409/503.
|
||||
conv.mu.Lock()
|
||||
busy := conv.Generating
|
||||
|
||||
@@ -215,12 +215,12 @@ func userMessageContent(files []attachInfo, prompt string) any {
|
||||
textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier
|
||||
continue
|
||||
}
|
||||
imgParts = append(imgParts, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
},
|
||||
})
|
||||
// Photo prise au téléphone : l'orientation vit dans un tag EXIF que le
|
||||
// projecteur ignore (il voit les pixels bruts, donc l'image couchée), et
|
||||
// une image de plusieurs milliers de pixels ne fait que gonfler le base64
|
||||
// — voir prepareImageForModel (web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
imgParts = append(imgParts, imageURLPart(b, mime))
|
||||
}
|
||||
if len(imgParts) == 0 {
|
||||
return attachNote(files) + prompt
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// web_upload_orient.go — préparation des images avant envoi au modèle :
|
||||
// redressement de l'orientation EXIF ET redimensionnement des images trop
|
||||
// grandes.
|
||||
//
|
||||
// Orientation : les photos prises au téléphone sont presque toujours
|
||||
// enregistrées dans l'orientation NATIVE du capteur (souvent paysage), avec un
|
||||
// tag EXIF Orientation qui dit au visualiseur de les tourner à l'affichage. Les
|
||||
// navigateurs et apps photo respectent ce tag, donc l'utilisateur voit l'image
|
||||
// droite ; mais le projecteur multimodal (mmproj) de llama.cpp IGNORE l'EXIF et
|
||||
// reçoit les pixels bruts. Le modèle voyait alors l'image tournée de 90°.
|
||||
//
|
||||
// Redimensionnement : une image de plusieurs milliers de pixels de côté est
|
||||
// inutilement lourde — le projecteur la redécoupe de toute façon à sa propre
|
||||
// résolution (quelques centaines de pixels par tuile). L'envoyer en pleine
|
||||
// définition ne fait que gonfler le base64 transféré et les tokens visuels, sans
|
||||
// rien apprendre de plus au modèle. On plafonne donc le plus grand côté à
|
||||
// maxImageDim : c'est ce que font les fournisseurs d'API vision (~1568 px), et
|
||||
// c'est visuellement sans perte à l'échelle où le modèle regarde. Les PNG
|
||||
// restent réencodés en PNG (sans perte, utile pour les captures d'écran à
|
||||
// texte) ; le reste part en JPEG.
|
||||
//
|
||||
// On CUIT donc orientation et taille dans les pixels : décoder, redresser,
|
||||
// redimensionner, réencoder. Repli systématique sur les octets bruts en cas de
|
||||
// pépin : préparer une image ne doit jamais faire échouer un envoi.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxImageDim plafonne le plus grand côté d'une image envoyée au modèle.
|
||||
maxImageDim = 1568
|
||||
// imageJPEGQuality : qualité de réencodage JPEG (photos). Assez haut pour
|
||||
// rester net à l'œil, assez bas pour couper franchement le poids du base64.
|
||||
imageJPEGQuality = 88
|
||||
)
|
||||
|
||||
// prepareImageForModel prend les octets d'une image et son type MIME, et renvoie
|
||||
// une version prête pour la vision : orientation EXIF cuite dans les pixels et
|
||||
// grand côté ramené sous maxImageDim. Si rien n'est à faire (bon format, pas de
|
||||
// tag, image déjà petite) ou si le décodage échoue (WEBP/BMP hors stdlib, octets
|
||||
// abîmés), elle renvoie les octets d'origine inchangés — jamais d'échec.
|
||||
func prepareImageForModel(raw []byte, mime string) ([]byte, string) {
|
||||
orient := 0
|
||||
if mime == "image/jpeg" {
|
||||
orient = exifOrientation(raw)
|
||||
}
|
||||
needOrient := orient >= 2 && orient <= 8
|
||||
|
||||
src, _, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return raw, mime // format non décodable ici (webp/bmp…) : laisser tel quel
|
||||
}
|
||||
b := src.Bounds()
|
||||
needResize := b.Dx() > maxImageDim || b.Dy() > maxImageDim
|
||||
if !needOrient && !needResize {
|
||||
return raw, mime // rien à changer : préserver les octets d'origine
|
||||
}
|
||||
|
||||
img := src
|
||||
if needOrient {
|
||||
img = applyOrientation(img, orient)
|
||||
}
|
||||
if img.Bounds().Dx() > maxImageDim || img.Bounds().Dy() > maxImageDim {
|
||||
img = scaleDownToMax(img, maxImageDim)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if mime == "image/png" {
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return raw, mime
|
||||
}
|
||||
return buf.Bytes(), "image/png"
|
||||
}
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: imageJPEGQuality}); err != nil {
|
||||
return raw, mime
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg"
|
||||
}
|
||||
|
||||
// toNRGBA renvoie une vue *image.NRGBA de src (sans copie s'il l'est déjà), pour
|
||||
// un accès rapide aux octets de pixels dans scaleDownToMax.
|
||||
func toNRGBA(src image.Image) *image.NRGBA {
|
||||
if n, ok := src.(*image.NRGBA); ok {
|
||||
return n
|
||||
}
|
||||
b := src.Bounds()
|
||||
n := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(n, n.Bounds(), src, b.Min, draw.Src)
|
||||
return n
|
||||
}
|
||||
|
||||
// scaleDownToMax réduit une image pour que son plus grand côté vaille au plus
|
||||
// maxDim, en conservant le ratio. Moyennage par aire (box filter) : chaque pixel
|
||||
// de destination est la moyenne des pixels source qu'il recouvre — le meilleur
|
||||
// filtre simple pour une RÉDUCTION (pas de crénelage ni de halo), sans
|
||||
// dépendance externe.
|
||||
func scaleDownToMax(src image.Image, maxDim int) image.Image {
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxDim && h <= maxDim {
|
||||
return src
|
||||
}
|
||||
scale := float64(maxDim) / float64(w)
|
||||
if h > w {
|
||||
scale = float64(maxDim) / float64(h)
|
||||
}
|
||||
nw := int(math.Round(float64(w) * scale))
|
||||
nh := int(math.Round(float64(h) * scale))
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
s := toNRGBA(src)
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, nw, nh))
|
||||
for dy := 0; dy < nh; dy++ {
|
||||
sy0 := dy * h / nh
|
||||
sy1 := (dy + 1) * h / nh
|
||||
if sy1 <= sy0 {
|
||||
sy1 = sy0 + 1
|
||||
}
|
||||
for dx := 0; dx < nw; dx++ {
|
||||
sx0 := dx * w / nw
|
||||
sx1 := (dx + 1) * w / nw
|
||||
if sx1 <= sx0 {
|
||||
sx1 = sx0 + 1
|
||||
}
|
||||
var r, g, bl, a, cnt uint64
|
||||
for sy := sy0; sy < sy1; sy++ {
|
||||
for sx := sx0; sx < sx1; sx++ {
|
||||
i := s.PixOffset(s.Rect.Min.X+sx, s.Rect.Min.Y+sy)
|
||||
r += uint64(s.Pix[i])
|
||||
g += uint64(s.Pix[i+1])
|
||||
bl += uint64(s.Pix[i+2])
|
||||
a += uint64(s.Pix[i+3])
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
di := dst.PixOffset(dx, dy)
|
||||
dst.Pix[di] = byte(r / cnt)
|
||||
dst.Pix[di+1] = byte(g / cnt)
|
||||
dst.Pix[di+2] = byte(bl / cnt)
|
||||
dst.Pix[di+3] = byte(a / cnt)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// exifOrientation renvoie la valeur du tag EXIF Orientation (1..8) d'un JPEG, ou
|
||||
// 0 si absent/illisible. On lit à la main le segment APP1 pour éviter une
|
||||
// dépendance : la structure est simple et le tag vit dans l'IFD0.
|
||||
func exifOrientation(b []byte) int {
|
||||
// En-tête JPEG : SOI = 0xFFD8.
|
||||
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
|
||||
return 0
|
||||
}
|
||||
pos := 2
|
||||
for pos+4 <= len(b) {
|
||||
if b[pos] != 0xFF {
|
||||
return 0 // désynchronisé
|
||||
}
|
||||
marker := b[pos+1]
|
||||
// SOS (début des données image) ou EOI : plus aucun segment de métadonnées.
|
||||
if marker == 0xDA || marker == 0xD9 {
|
||||
return 0
|
||||
}
|
||||
segLen := int(binary.BigEndian.Uint16(b[pos+2 : pos+4]))
|
||||
if segLen < 2 || pos+2+segLen > len(b) {
|
||||
return 0
|
||||
}
|
||||
seg := b[pos+4 : pos+2+segLen]
|
||||
// APP1 = 0xFFE1, préfixé "Exif\0\0" pour les données EXIF.
|
||||
if marker == 0xE1 && len(seg) >= 6 && string(seg[:6]) == "Exif\x00\x00" {
|
||||
return exifOrientationFromTIFF(seg[6:])
|
||||
}
|
||||
pos += 2 + segLen
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// exifOrientationFromTIFF lit l'orientation dans le bloc TIFF d'un segment EXIF.
|
||||
func exifOrientationFromTIFF(t []byte) int {
|
||||
if len(t) < 8 {
|
||||
return 0
|
||||
}
|
||||
var bo binary.ByteOrder
|
||||
switch string(t[:2]) {
|
||||
case "II":
|
||||
bo = binary.LittleEndian
|
||||
case "MM":
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
if bo.Uint16(t[2:4]) != 0x002A {
|
||||
return 0
|
||||
}
|
||||
ifdOff := int(bo.Uint32(t[4:8]))
|
||||
if ifdOff < 8 || ifdOff+2 > len(t) {
|
||||
return 0
|
||||
}
|
||||
n := int(bo.Uint16(t[ifdOff : ifdOff+2]))
|
||||
entry := ifdOff + 2
|
||||
for i := 0; i < n; i++ {
|
||||
if entry+12 > len(t) {
|
||||
return 0
|
||||
}
|
||||
tag := bo.Uint16(t[entry : entry+2])
|
||||
if tag == 0x0112 { // Orientation
|
||||
// Type SHORT : la valeur tient dans les 2 premiers octets du champ valeur
|
||||
// (offset entry+8).
|
||||
return int(bo.Uint16(t[entry+8 : entry+10]))
|
||||
}
|
||||
entry += 12
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// applyOrientation renvoie une nouvelle image où l'orientation EXIF a été cuite
|
||||
// dans les pixels. Les 8 cas du tag combinent rotations (multiples de 90°) et
|
||||
// miroirs.
|
||||
func applyOrientation(src image.Image, orient int) image.Image {
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
// Les orientations 5..8 échangent largeur et hauteur (rotation d'un quart).
|
||||
swap := orient >= 5
|
||||
dw, dh := w, h
|
||||
if swap {
|
||||
dw, dh = h, w
|
||||
}
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, dw, dh))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
c := src.At(b.Min.X+x, b.Min.Y+y)
|
||||
var nx, ny int
|
||||
switch orient {
|
||||
case 2: // miroir horizontal
|
||||
nx, ny = w-1-x, y
|
||||
case 3: // 180°
|
||||
nx, ny = w-1-x, h-1-y
|
||||
case 4: // miroir vertical
|
||||
nx, ny = x, h-1-y
|
||||
case 5: // transpose (diagonale principale)
|
||||
nx, ny = y, x
|
||||
case 6: // 90° horaire
|
||||
nx, ny = h-1-y, x
|
||||
case 7: // transverse (diagonale secondaire)
|
||||
nx, ny = h-1-y, w-1-x
|
||||
case 8: // 90° anti-horaire
|
||||
nx, ny = y, w-1-x
|
||||
default:
|
||||
nx, ny = x, y
|
||||
}
|
||||
dst.Set(nx, ny, c)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildExifJPEG fabrique un JPEG minimal portant un tag EXIF Orientation donné,
|
||||
// pour tester le parseur sans dépendre d'un fichier binaire.
|
||||
func buildExifJPEG(t *testing.T, orient uint16, img image.Image) []byte {
|
||||
t.Helper()
|
||||
// Bloc TIFF (little-endian) avec un unique IFD0 : 1 entrée = Orientation.
|
||||
var tiff bytes.Buffer
|
||||
tiff.WriteString("II")
|
||||
binary.Write(&tiff, binary.LittleEndian, uint16(0x002A))
|
||||
binary.Write(&tiff, binary.LittleEndian, uint32(8)) // IFD0 juste après l'en-tête
|
||||
binary.Write(&tiff, binary.LittleEndian, uint16(1)) // 1 entrée
|
||||
binary.Write(&tiff, binary.LittleEndian, uint16(0x0112))
|
||||
binary.Write(&tiff, binary.LittleEndian, uint16(3)) // type SHORT
|
||||
binary.Write(&tiff, binary.LittleEndian, uint32(1)) // count
|
||||
binary.Write(&tiff, binary.LittleEndian, orient)
|
||||
binary.Write(&tiff, binary.LittleEndian, uint16(0)) // padding du champ valeur
|
||||
binary.Write(&tiff, binary.LittleEndian, uint32(0)) // next IFD = 0
|
||||
|
||||
exif := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
|
||||
|
||||
var out bytes.Buffer
|
||||
out.Write([]byte{0xFF, 0xD8}) // SOI
|
||||
// APP1
|
||||
out.Write([]byte{0xFF, 0xE1})
|
||||
binary.Write(&out, binary.BigEndian, uint16(len(exif)+2))
|
||||
out.Write(exif)
|
||||
// Corps JPEG réel (sans son propre SOI, qu'on saute).
|
||||
var body bytes.Buffer
|
||||
if err := jpeg.Encode(&body, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Write(body.Bytes()[2:]) // saute le SOI dupliqué
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func TestExifOrientationParsing(t *testing.T) {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 4, 2))
|
||||
for _, want := range []int{1, 3, 6, 8} {
|
||||
b := buildExifJPEG(t, uint16(want), img)
|
||||
if got := exifOrientation(b); got != want {
|
||||
t.Errorf("orientation %d : lu %d", want, got)
|
||||
}
|
||||
}
|
||||
// Un JPEG sans EXIF renvoie 0.
|
||||
var plain bytes.Buffer
|
||||
if err := jpeg.Encode(&plain, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := exifOrientation(plain.Bytes()); got != 0 {
|
||||
t.Errorf("sans EXIF : attendu 0, lu %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOrientationDimensions(t *testing.T) {
|
||||
// 4 de large, 2 de haut. Une rotation d'un quart (6, 8) doit échanger les axes.
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 4, 2))
|
||||
if d := applyOrientation(src, 6).Bounds(); d.Dx() != 2 || d.Dy() != 4 {
|
||||
t.Errorf("orient 6 : %dx%d, attendu 2x4", d.Dx(), d.Dy())
|
||||
}
|
||||
if d := applyOrientation(src, 3).Bounds(); d.Dx() != 4 || d.Dy() != 2 {
|
||||
t.Errorf("orient 3 : %dx%d, attendu 4x2", d.Dx(), d.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaleDownToMax(t *testing.T) {
|
||||
// Grande image paysage : le plus grand côté doit tomber à maxImageDim, ratio
|
||||
// conservé, et une image déjà petite ne bouge pas.
|
||||
big := image.NewNRGBA(image.Rect(0, 0, 4000, 2000))
|
||||
d := scaleDownToMax(big, maxImageDim).Bounds()
|
||||
if d.Dx() != maxImageDim {
|
||||
t.Errorf("grand côté : %d, attendu %d", d.Dx(), maxImageDim)
|
||||
}
|
||||
if d.Dy() != maxImageDim/2 {
|
||||
t.Errorf("petit côté : %d, attendu %d (ratio 2:1)", d.Dy(), maxImageDim/2)
|
||||
}
|
||||
small := image.NewNRGBA(image.Rect(0, 0, 100, 80))
|
||||
if sd := scaleDownToMax(small, maxImageDim).Bounds(); sd.Dx() != 100 || sd.Dy() != 80 {
|
||||
t.Errorf("image petite modifiée : %dx%d", sd.Dx(), sd.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareImageForModelResizesJPEG(t *testing.T) {
|
||||
// Un JPEG plus grand que maxImageDim doit être réencodé plus petit ; ses octets
|
||||
// changent donc forcément.
|
||||
big := image.NewNRGBA(image.Rect(0, 0, 3000, 1500))
|
||||
var raw bytes.Buffer
|
||||
if err := jpeg.Encode(&raw, big, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, mime := prepareImageForModel(raw.Bytes(), "image/jpeg")
|
||||
if mime != "image/jpeg" {
|
||||
t.Fatalf("mime = %s", mime)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img.Bounds().Dx() != maxImageDim {
|
||||
t.Errorf("largeur après préparation : %d, attendu %d", img.Bounds().Dx(), maxImageDim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOrientationRotate90(t *testing.T) {
|
||||
// Pixel repère en haut-gauche du capteur ; en orientation 6 (90° horaire) il
|
||||
// doit se retrouver en haut-droite de l'image redressée.
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 3, 2))
|
||||
for y := 0; y < 2; y++ {
|
||||
for x := 0; x < 3; x++ {
|
||||
src.Set(x, y, color.NRGBA{0, 0, 0, 255})
|
||||
}
|
||||
}
|
||||
src.Set(0, 0, color.NRGBA{255, 0, 0, 255})
|
||||
dst := applyOrientation(src, 6)
|
||||
// dst est 2 de large, 3 de haut ; le repère va en (dw-1, 0).
|
||||
r := color.NRGBAModel.Convert(dst.At(dst.Bounds().Dx()-1, 0)).(color.NRGBA)
|
||||
if r.R != 255 {
|
||||
t.Errorf("orient 6 : repère mal placé, got %+v", r)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user