mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 06:58:14 +02:00
Compare commits
36
Commits
513aca1c7b
...
cf8d1cfaaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf8d1cfaaa | ||
|
|
b955181bb9 | ||
|
|
15495431fc | ||
|
|
d5632e3595 | ||
|
|
dfafca6fb2 | ||
|
|
2e0356cd6c | ||
|
|
a745d2399e | ||
|
|
ad87f2fc9f | ||
|
|
7b754c0cb6 | ||
|
|
9ccdc021cb | ||
|
|
5e36b1cf82 | ||
|
|
2bbf0ba76d | ||
|
|
8171899ac1 | ||
|
|
199d7f1e99 | ||
|
|
7f8c5d360f | ||
|
|
2db0ecd431 | ||
|
|
48b8193dca | ||
|
|
58c2da1302 | ||
|
|
ae0e181711 | ||
|
|
6aafeeea98 | ||
|
|
f54c304019 | ||
|
|
51c791268d | ||
|
|
04137411fa | ||
|
|
f7a4ee71e7 | ||
|
|
1db987844e | ||
|
|
f75f3cf27d | ||
|
|
38802d9e21 | ||
|
|
2c36fa6d2c | ||
|
|
4a1054c1b7 | ||
|
|
626227f2f2 | ||
|
|
4602614be0 | ||
|
|
f117a466af | ||
|
|
97378da7af | ||
|
|
e12f07426f | ||
|
|
20375d0e4c | ||
|
|
b893d1a81f |
No files matched your search
@@ -27,10 +27,15 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
|
||||
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. 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.
|
||||
ajoutent depuis d'autres 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), la **vérification déclenchée quand le builder a
|
||||
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
|
||||
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
|
||||
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
|
||||
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
|
||||
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
|
||||
qui garde l'état du mode Code.
|
||||
|
||||
## Reprises depuis l'amont après le fork
|
||||
|
||||
|
||||
@@ -124,6 +124,15 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `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) | — |
|
||||
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
|
||||
|
||||
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
|
||||
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
|
||||
toute requête venue d'un autre site : une page web ouverte dans un navigateur
|
||||
du réseau ne peut plus piloter Loki en douce (ni par DNS rebinding). Derrière
|
||||
un reverse proxy (`loki.mondomaine.fr`), définir une clé
|
||||
(`docker exec -it loki loki set-web-key`) ou lister le nom dans
|
||||
`LOKI_TRUSTED_HOSTS`.
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
@@ -326,11 +335,6 @@ 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
|
||||
|
||||
+72
-78
@@ -1,96 +1,90 @@
|
||||
# Loki 0.13.0
|
||||
# Loki 0.14.0
|
||||
|
||||
Loki rattrape deux versions majeures d'AJEAN (0.14 → 0.15.4) et reprend deux
|
||||
idées d'OpenFox. L'IA peut piloter un navigateur, la mémoire se chiffre sur le
|
||||
disque, et le serveur sait te prévenir quand c'est prêt.
|
||||
Loki rattrape AJEAN jusqu'à la 0.17.6 et reprend une série d'idées d'OpenFox
|
||||
pour le mode Code. Le gros du travail est invisible : des discussions qui ne se
|
||||
bloquent plus sur un contexte plein, des réponses qui survivent à une coupure
|
||||
réseau, un agent de code qui gaspille moins de tours.
|
||||
|
||||
## L'IA pilote un navigateur
|
||||
## Le contexte ne bloque plus la discussion
|
||||
|
||||
Un interrupteur dans *Réglages → Contrôle du navigateur* et l'IA ouvre des pages
|
||||
dans le Chromium déjà embarqué dans l'image. Elle en reçoit les éléments
|
||||
interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit par numéro :
|
||||
`browser_open`, `browser_find`, `browser_click`, `browser_type`, `browser_key`,
|
||||
`browser_scroll`. **Aucune vision requise** — ça marche avec un petit modèle
|
||||
texte. Avec un projecteur `mmproj` chargé s'ajoutent `browser_screenshot`
|
||||
(capture quadrillée tous les 100 px) et `browser_click_xy`, pour ce que l'arbre
|
||||
d'accessibilité ne montre pas : canvas, bandeau cookies en iframe.
|
||||
Sur une fenêtre de 65k, un tour qui lisait plusieurs gros fichiers d'un coup
|
||||
passait de 60 % à plus de 130 % sans jamais compacter, et le moteur répondait
|
||||
par un 400 qui figeait la discussion.
|
||||
|
||||
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`.
|
||||
En CLI : `loki computer [on|off|status]`.
|
||||
- Le compactage en cours de tour compte aussi les résultats d'outils que le
|
||||
moteur n'a pas encore vus.
|
||||
- Tout résultat d'outil est borné à 30 000 caractères **pour le modèle** ;
|
||||
l'interface garde le résultat complet (« voir plus »).
|
||||
- En dernier recours, les gros résultats sont tronqués puis les plus vieux
|
||||
échanges retirés, au lieu de laisser remonter l'erreur.
|
||||
- Le compactage ne fait plus répondre deux fois à une vieille question, et le
|
||||
texte écrit avant un appel d'outil n'est plus rangé en double.
|
||||
|
||||
## La mémoire se chiffre
|
||||
## Presets : API compatible OpenAI
|
||||
|
||||
*Réglages → Mémoire → chiffrer la mémoire sur le disque* : pages mémoire,
|
||||
discussions, blocs archivés au compactage et trackers passent en **AES-256-GCM**
|
||||
sous `/data`. La clé de données est enfermée dans un coffre par une clé dérivée
|
||||
en **Argon2id** ; ce qui l'ouvre, c'est la **clé de pilotage de cet 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.
|
||||
Les presets d'API externe gagnent ce qui leur manquait :
|
||||
|
||||
La clé ne vit qu'en RAM : après un redémarrage à froid, la mémoire reste
|
||||
verrouillée jusqu'à ce qu'un navigateur se reconnecte. Verrouillé, Loki n'écrase
|
||||
jamais du chiffré par du clair — il refuse d'écrire. Un **snapshot** est pris
|
||||
avant chaque bascule, une migration interrompue reprend au démarrage, et rien
|
||||
n'est supprimé avant que son remplaçant ait été relu et vérifié.
|
||||
- une case **« le modèle accepte les images »** pour un modèle distant
|
||||
multimodal ;
|
||||
- plus de `chat_template_kwargs` (propre à llama.cpp) envoyé à l'API : une API
|
||||
stricte répondait 400 et **chaque compactage échouait** ;
|
||||
- **reprise automatique** quand le flux se coupe en pleine réponse (Wi-Fi, VPN,
|
||||
proxy) : jusqu'à 5 fois, le modèle reprend là où il s'était arrêté ;
|
||||
- un 401, 429 ou 502 n'est plus pris pour un appel d'outil mal formé ;
|
||||
- le débit est mesuré même quand l'API ne le donne pas ; le benchmark refuse de
|
||||
mesurer un moteur local qui n'est pas celui du preset ; le badge des réponses
|
||||
porte le nom du modèle distant.
|
||||
|
||||
**Sauvegarde chiffrée** : *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. Le fichier reste chez toi : aucun envoi
|
||||
vers un service tiers.
|
||||
## Mode Code
|
||||
|
||||
## Notifications, même app fermée
|
||||
- **La vérification attend que le builder ait fini.** Il marque chaque critère
|
||||
`completed` une fois fait ; tant qu'il en reste d'ouverts, il est relancé au
|
||||
lieu de payer une passe de vérification sur un travail inachevé. Plus aucune
|
||||
vérification ne part quand il vient de poser une question.
|
||||
- **Écriture refusée dès son chemin** : un `write` sur un fichier jamais lu
|
||||
était refusé après tout le fichier généré. Le chemin passe maintenant en
|
||||
premier et le refus coupe la génération aussitôt.
|
||||
- **Appel d'outil écrit en texte** (`<tool_call>`, format XML de Qwen3-Coder) :
|
||||
repéré pendant le flux, coupé, et la relance cite l'extrait fautif.
|
||||
- `read_file`, `str_replace`, `old_string`… appris d'autres agents sont
|
||||
traduits vers les vrais outils.
|
||||
- Les consignes du dépôt (**AGENTS.md**, CLAUDE.md) font partie du contexte.
|
||||
- Terminal : `cmd | tail -N` rend le **vrai** code de sortie, la sortie déjà
|
||||
produite est gardée au délai dépassé, les couleurs ANSI sont retirées.
|
||||
- `git_status` / `git_diff` trouvent le dépôt cloné dans un sous-dossier.
|
||||
- Le compactage garde les fichiers touchés, les erreurs résolues et l'état des
|
||||
critères.
|
||||
|
||||
Le serveur pousse une notification à la fin d'une réponse **et à la fin d'une
|
||||
tâche planifiée**, succès comme échec — c'est le cas qui compte, personne ne
|
||||
regarde. Interrupteur dans *Réglages → Mode agent*, à armer sur chaque appareil.
|
||||
Demande HTTPS (ou localhost) ; sur iPhone, ajoute d'abord Loki à l'écran
|
||||
d'accueil.
|
||||
## Tâches et chat
|
||||
|
||||
## Tâches : des scripts qui tournent sans modèle
|
||||
- **« Rappelle-moi dans 20 minutes »** : `task_create` accepte `in_minutes` ou
|
||||
`at`, à l'heure du navigateur (le conteneur tourne en UTC).
|
||||
- **Écrire pendant que l'IA répond** : le message part en file et le modèle en
|
||||
tient compte dans la suite de sa réponse ; deux appareils qui envoient en
|
||||
même temps ne se refusent plus.
|
||||
- Longues discussions : au chargement, les 20 derniers échanges, le début d'un
|
||||
clic ; la liste des discussions se dessine par pages et montre la nouvelle
|
||||
dès le premier message.
|
||||
- Mémoire : un mode par projet, et un quatrième, « recherche ».
|
||||
- Appels d'outils parallèles séparés selon leur index ; bascule de preset par
|
||||
identifiant ; un raisonnement qui reprend après la réponse a sa propre bulle.
|
||||
|
||||
Un nouveau dossier `/data/scripts`, **hors du workspace jetable** : supprimer une
|
||||
discussion n'y touche pas. Une tâche peut désormais être un **script seul** — le
|
||||
planificateur le lance sans charger le modèle ni consommer un jeton. Une
|
||||
sauvegarde, une synchro, un nettoyage n'ont rien à demander à un LLM.
|
||||
## Sécurité et données
|
||||
|
||||
L'IA dispose aussi de `task_create`, `task_list`, `task_update` et `task_delete` :
|
||||
elle se pose ses propres rappels et veilles, cloisonnés par projet.
|
||||
- **Un site tiers ne peut plus piloter Loki** depuis le navigateur (Origin /
|
||||
Sec-Fetch-Site, DNS rebinding). ⚠️ Derrière un reverse proxy par nom de
|
||||
domaine, définis une clé de pilotage ou `LOKI_TRUSTED_HOSTS` **avant** la mise
|
||||
à jour, et vérifie que le proxy transmet l'en-tête `Host` d'origine.
|
||||
- **Chiffrement de la mémoire** : l'activer rendait illisibles la discussion
|
||||
active et le mode Code. Corrigé ; une base déjà touchée est réparée au
|
||||
déverrouillage. Les résultats d'outils (« voir plus ») et les images du fil
|
||||
sont désormais chiffrés eux aussi, et les images effacées après 24 h.
|
||||
|
||||
## Mode code : des sous-agents
|
||||
## Moteur
|
||||
|
||||
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 la
|
||||
fenêtre de contexte qu'on sauve : « trouve où est géré le cache » coûte dix
|
||||
lectures de fichiers, qui restaient sinon dans l'historique alors que seule la
|
||||
réponse comptait. Tous les rôles délégués sont en lecture seule.
|
||||
|
||||
Le builder ne publie plus de lui-même : sans demande explicite, ni commit, ni
|
||||
push, ni redémarrage de service.
|
||||
|
||||
## Interface
|
||||
|
||||
- **Anglais** (*Apparence → Langue*) : la coque — navigation, intitulés,
|
||||
boutons — passe en anglais. Ce qui n'est pas encore traduit reste en français
|
||||
plutôt que d'afficher une clé technique, et le fil de discussion n'est jamais
|
||||
touché.
|
||||
- **Résultats d'outils** : le flux ne transporte plus qu'un aperçu ; « voir
|
||||
plus » charge le reste à la demande et déplie vraiment le bloc. Le compteur
|
||||
« ~N tok » dit enfin la taille réelle, pas celle de l'aperçu.
|
||||
- **Longues discussions** : à l'ouverture, seule la fin du fil est rejouée. Un
|
||||
bandeau dit combien d'événements sont masqués et charge le début d'un clic.
|
||||
- **Images** : l'orientation EXIF est cuite dans les pixels (fini les photos de
|
||||
téléphone couchées pour le modèle) et le grand côté ramené sous 1568 px.
|
||||
|
||||
## Corrections
|
||||
|
||||
- Un outil qui porte une image dans un message séparé (`see_image`,
|
||||
`browser_screenshot`) renvoyait « [déjà fait] » **sans** l'image et le modèle
|
||||
bouclait. Relancer la même commande `bash` est de nouveau permis.
|
||||
- **MCP** ne tronque plus sa réponse à 12000 caractères avant le modèle.
|
||||
- Le dossier mémoire n'est plus joignable qu'aux outils `mem_*` : un `cat
|
||||
memory/…` contournait l'index `MEMORY.md`.
|
||||
- `--mlock` seul ne coupe plus le mmap (OOM au chargement).
|
||||
- Port déjà occupé refusé avec un message clair ; cache KV lent signalé.
|
||||
- Réponses web compressées en gzip.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,13 +2,13 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 13,
|
||||
"Minor": 14,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 13,
|
||||
"Minor": 14,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||
"OriginalFilename": "loki.exe",
|
||||
"ProductName": "Loki",
|
||||
"ProductVersion": "0.13.0",
|
||||
"ProductVersion": "0.14.0",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -10,7 +10,7 @@ Method — in this order:
|
||||
Rules:
|
||||
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
|
||||
- Write each file COMPLETE in a single write call. Many small writes waste turns.
|
||||
- You may NOT mark a criterion passed — the verification pass does that.
|
||||
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
|
||||
- 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.
|
||||
@@ -24,10 +24,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
|
||||
)
|
||||
|
||||
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
|
||||
@@ -46,6 +47,14 @@ func isExternalConfig(cfg map[string]string) bool {
|
||||
// externalActive : le preset actif est-il un endpoint externe ?
|
||||
func externalActive() bool { return isExternalConfig(ReadConfig()) }
|
||||
|
||||
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
|
||||
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
|
||||
// preset dit si le modèle accepte les images.
|
||||
func externalVisionActive() bool {
|
||||
cfg := ReadConfig()
|
||||
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
|
||||
}
|
||||
|
||||
// completionsURL normalise l'URL saisie en une URL de complétions complète.
|
||||
// Accepte les trois formes qu'on colle en pratique :
|
||||
//
|
||||
@@ -102,8 +111,9 @@ func (e chatEndpoint) auth(set func(k, v string)) {
|
||||
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
|
||||
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
|
||||
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
|
||||
// compaction, exactement comme pour un preset local.
|
||||
func externalPresetContent(url, model, key, ctx string) string {
|
||||
// compaction, exactement comme pour un preset local. vision = le modèle distant
|
||||
// accepte les images (EXTERNAL_VISION=1).
|
||||
func externalPresetContent(url, model, key, ctx string, vision bool) string {
|
||||
m := map[string]string{
|
||||
extKeyFlag: "1",
|
||||
extKeyURL: strings.TrimSpace(url),
|
||||
@@ -112,6 +122,9 @@ func externalPresetContent(url, model, key, ctx string) string {
|
||||
if k := strings.TrimSpace(key); k != "" {
|
||||
m[extKeyToken] = k
|
||||
}
|
||||
if vision {
|
||||
m[extKeyVision] = "1"
|
||||
}
|
||||
if c := strings.TrimSpace(ctx); c != "" {
|
||||
m["CTX"] = c
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -46,7 +47,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep = resolveChatEndpoint()
|
||||
@@ -71,7 +72,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
|
||||
@@ -142,7 +143,7 @@ func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
|
||||
// d'effacement.
|
||||
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -179,7 +180,7 @@ func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
// Un preset externe demande explicitement d'écraser la clé par une vide.
|
||||
func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -194,3 +195,51 @@ func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
|
||||
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
|
||||
// échouait à chaque fois.
|
||||
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if _, ok := body["chat_template_kwargs"]; ok {
|
||||
w.WriteHeader(400)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
|
||||
t.Fatalf("compactage refusé par l'API distante : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Vision et benchmark d'un preset externe : la case du preset décide, et le
|
||||
// bench (qui mesure le moteur local) refuse de tourner.
|
||||
func TestPresetExterneVisionEtBench(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !visionEnabled() || !engineSeesImages() {
|
||||
t.Error("preset externe déclaré multimodal : la vision devrait être active")
|
||||
}
|
||||
if _, err := runBench(10, 10); err == nil {
|
||||
t.Error("benchmark lancé sur un preset externe")
|
||||
}
|
||||
if got := chatModelName(); got != "gpt-4o" {
|
||||
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
|
||||
}
|
||||
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if visionEnabled() {
|
||||
t.Error("preset externe texte seul : la vision devrait être coupée")
|
||||
}
|
||||
}
|
||||
@@ -432,6 +432,9 @@ func DeletePreset(id string) error {
|
||||
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
|
||||
// sous le même id.
|
||||
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
|
||||
// Même ménage pour son benchmark : un preset recréé sous cet id en
|
||||
// hériterait, alors qu'il a été mesuré sur un autre modèle.
|
||||
deletePresetBench(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+148
-13
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -140,22 +141,34 @@ func hasAnyFlag(args []string, flags ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
|
||||
// --load-mode, sur les moteurs qui le connaissent :
|
||||
// normalizeLoadFlags choisit, au lancement, la forme des drapeaux de chargement
|
||||
// que CE binaire comprend. llama.cpp récent a remplacé --mlock / --no-mmap par
|
||||
// --load-mode (et refuse les anciens) ; un moteur ancien ou un fork ne connaît
|
||||
// que les anciens. L'éditeur écrit --load-mode, un preset d'avant peut porter
|
||||
// les anciens : on traduit dans le sens qu'il faut (repris d'AJEAN 0.16.0).
|
||||
//
|
||||
// --mlock → --load-mode mlock (garder le modèle en RAM)
|
||||
// --no-mmap → --load-mode none (tout charger, pas de mmap)
|
||||
// --mmap → --load-mode mmap
|
||||
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
|
||||
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
|
||||
//
|
||||
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
|
||||
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
|
||||
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
|
||||
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
|
||||
// --mlock seul → mmap+mlock (l'ancien sens de --mlock : mmap conservé)
|
||||
// --no-mmap seul → none
|
||||
// --mlock + --no-mmap → mlock
|
||||
// --mmap → mmap
|
||||
//
|
||||
// ⚠️ Jusqu'ici --mlock seul devenait « mlock », qui COUPE le mmap : le modèle
|
||||
// entier montait en RAM au lieu d'être mappé — un OOM assuré sur un modèle plus
|
||||
// gros que la mémoire. Un --load-mode déjà écrit gagne sur tout : on retire
|
||||
// alors simplement les vieux drapeaux.
|
||||
//
|
||||
// Vers les anciens drapeaux (moteur qui ne connaît pas --load-mode) :
|
||||
//
|
||||
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
|
||||
// auto, mmap, dio → rien (défaut de l'ancien moteur)
|
||||
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||
if !supportsLoadMode {
|
||||
return args
|
||||
return downgradeLoadMode(args)
|
||||
}
|
||||
explicit := hasAnyFlag(args, "--load-mode")
|
||||
explicit := hasAnyFlag(args, "--load-mode", "-lm")
|
||||
mlock, nommap, mmap := false, false, false
|
||||
out := make([]string, 0, len(args)+2)
|
||||
for _, a := range args {
|
||||
@@ -175,14 +188,62 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||
}
|
||||
mode := "mmap"
|
||||
switch {
|
||||
case mlock:
|
||||
case mlock && nommap:
|
||||
mode = "mlock"
|
||||
case mlock:
|
||||
mode = "mmap+mlock"
|
||||
case nommap:
|
||||
mode = "none"
|
||||
}
|
||||
return append(out, "--load-mode", mode)
|
||||
}
|
||||
|
||||
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
|
||||
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
|
||||
func downgradeLoadMode(args []string) []string {
|
||||
mode, found := "", false
|
||||
kept := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--load-mode" || a == "-lm" {
|
||||
found = true
|
||||
if i+1 < len(args) {
|
||||
mode = strings.ToLower(args[i+1])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "--load-mode="); ok {
|
||||
found, mode = true, strings.ToLower(v)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, a)
|
||||
}
|
||||
if !found {
|
||||
return args
|
||||
}
|
||||
add := func(flag string) {
|
||||
for _, a := range kept {
|
||||
if a == flag {
|
||||
return
|
||||
}
|
||||
}
|
||||
kept = append(kept, flag)
|
||||
}
|
||||
switch mode {
|
||||
case "none":
|
||||
add("--no-mmap")
|
||||
case "mlock":
|
||||
add("--mlock")
|
||||
add("--no-mmap")
|
||||
case "mmap+mlock":
|
||||
add("--mlock")
|
||||
case "dio":
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut\n")
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// cmdServe replaces the historic start.sh: read config.env, build the
|
||||
// llama-server invocation, and exec it (replacing this process so systemd
|
||||
// supervises llama-server directly).
|
||||
@@ -370,10 +431,84 @@ func cmdServe(args []string) error {
|
||||
// mmproj-F16.gguf) still resolve.
|
||||
_ = os.Chdir(LokiHome())
|
||||
|
||||
// Port déjà pris (souvent un llama-server orphelin qu'un stop n'a pas pu
|
||||
// tuer) : deux moteurs sur un même port se partagent les requêtes au hasard,
|
||||
// VRAM saturée et réponses du mauvais modèle. On refuse en clair.
|
||||
host, port := argValue(llmArgs, "--host"), argValue(llmArgs, "--port")
|
||||
if err := waitPortFree(host, port, 5*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
warnSlowKV(ktv, vtv)
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), get("PORT", "8080"))
|
||||
bin, filepath.Base(model), port)
|
||||
|
||||
// Hand off to the llama-server process. On Unix this replaces the current
|
||||
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
|
||||
return execServer(bin, llmArgs)
|
||||
}
|
||||
|
||||
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
|
||||
// retient la dernière : EXTRA_ARGS peut surcharger --port).
|
||||
func argValue(args []string, flag string) string {
|
||||
v := ""
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
v = args[i+1]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// waitPortFree vérifie que personne n'écoute déjà sur host:port. On tente une
|
||||
// connexion TCP (fiable même si l'occupant a bindé en SO_REUSEADDR, où un
|
||||
// Listen de test pourrait réussir). Un moteur qu'on vient d'arrêter peut tenir
|
||||
// le port quelques instants : on réessaie jusqu'à wait avant de conclure.
|
||||
// Repris d'AJEAN 0.15.9.
|
||||
func waitPortFree(host, port string, wait time.Duration) error {
|
||||
if port == "" {
|
||||
return nil
|
||||
}
|
||||
h := strings.Trim(host, "[]")
|
||||
if h == "" || h == "0.0.0.0" || h == "::" {
|
||||
h = "127.0.0.1"
|
||||
}
|
||||
addr := net.JoinHostPort(h, port)
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c.Close()
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("le port %s est déjà utilisé (sans doute un ancien llama-server encore actif) : "+
|
||||
"« loki stop », docker restart loki, ou change PORT", port)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// fastKV : combinaisons K/V que llama.cpp CUDA accélère en Flash-Attention
|
||||
// avec ses options de compilation par défaut (GGML_CUDA_FA_ALL_QUANTS=OFF) —
|
||||
// c'est le cas du moteur de l'image server-cuda comme de ceux tirés par OCI.
|
||||
// Les autres (q5_*, q4_1, combinaisons mixtes) marchent mais sont reconverties
|
||||
// en f16 à chaque pas : la génération ralentit nettement (AJEAN 0.15.9).
|
||||
var fastKV = map[string]bool{"f16|f16": true, "q8_0|q8_0": true, "q4_0|q4_0": true, "bf16|bf16": true}
|
||||
|
||||
func slowKV(k, v string) bool {
|
||||
if k == "" {
|
||||
k = "f16"
|
||||
}
|
||||
if v == "" {
|
||||
v = "f16"
|
||||
}
|
||||
return !fastKV[k+"|"+v]
|
||||
}
|
||||
|
||||
func warnSlowKV(k, v string) {
|
||||
if slowKV(k, v) {
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] avertissement : cache KV %s/%s non accéléré par llama.cpp CUDA "+
|
||||
"(converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16.\n", k, v)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHasAnyFlag(t *testing.T) {
|
||||
@@ -31,16 +33,25 @@ func TestNormalizeLoadFlags(t *testing.T) {
|
||||
supports bool
|
||||
want []string
|
||||
}{
|
||||
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
|
||||
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
{"mlock traduit", []string{"--mlock", "-fa"}, true,
|
||||
[]string{"-fa", "--load-mode", "mlock"}},
|
||||
// L'ancien --mlock gardait le mmap : « mlock » de llama.cpp le coupe.
|
||||
{"mlock seul → mmap+mlock", []string{"--mlock", "-fa"}, true,
|
||||
[]string{"-fa", "--load-mode", "mmap+mlock"}},
|
||||
{"no-mmap traduit", []string{"--no-mmap"}, true,
|
||||
[]string{"--load-mode", "none"}},
|
||||
{"mmap traduit", []string{"--mmap"}, true,
|
||||
[]string{"--load-mode", "mmap"}},
|
||||
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
|
||||
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
|
||||
[]string{"--load-mode", "mlock"}},
|
||||
{"moteur ancien : none retraduit", []string{"-fa", "--load-mode", "none"}, false,
|
||||
[]string{"-fa", "--no-mmap"}},
|
||||
{"moteur ancien : mlock retraduit", []string{"--load-mode", "mlock"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
{"moteur ancien : mmap+mlock retraduit", []string{"-lm", "mmap+mlock"}, false,
|
||||
[]string{"--mlock"}},
|
||||
{"moteur ancien : dio abandonné", []string{"--load-mode=dio", "-fa"}, false,
|
||||
[]string{"-fa"}},
|
||||
{"--load-mode explicite gagne, vieux drapeaux retirés",
|
||||
[]string{"--mlock", "--load-mode", "dio"}, true,
|
||||
[]string{"--load-mode", "dio"}},
|
||||
@@ -91,3 +102,32 @@ func TestNGLArgs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgValueEtPortLibre(t *testing.T) {
|
||||
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
|
||||
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
|
||||
t.Fatal("un port occupé doit être refusé")
|
||||
}
|
||||
ln.Close()
|
||||
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
|
||||
t.Fatalf("un port libre doit passer : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowKV(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
k, v string
|
||||
lent bool
|
||||
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
|
||||
if slowKV(c.k, c.v) != c.lent {
|
||||
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -379,7 +380,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
}
|
||||
}
|
||||
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
|
||||
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
var mid []Message
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
@@ -392,8 +393,18 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
// system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
|
||||
// Le message porte aussi la CONSCIENCE de la compaction et l'index des ids
|
||||
// rappelables (voir compactSummaryUserMsg).
|
||||
sumMsg := compactSummaryUserMsg(summary, index)
|
||||
// Mode Code : les critères ne vivent pas dans le fil (seuls les appels à
|
||||
// l'outil criteria y passent, et ils viennent d'être résumés). On rend
|
||||
// leur état au builder ici, dans un message de toute façon neuf — pas de
|
||||
// cache invalidé en plus (repris d'OpenFox).
|
||||
if caps.Code {
|
||||
if list := critList(convEnsureActive()); len(list) > 0 {
|
||||
sumMsg += "\n\nAcceptance criteria (current state):\n" + critRender(list)
|
||||
}
|
||||
}
|
||||
mid = []Message{
|
||||
{Role: "user", Content: compactSummaryUserMsg(summary, index)},
|
||||
{Role: "user", Content: sumMsg},
|
||||
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."},
|
||||
}
|
||||
}
|
||||
@@ -408,8 +419,23 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
// On réinjecte donc textuellement la dernière vraie demande du torse, juste
|
||||
// avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
|
||||
// dans l'historique d'origine). Le torse reste entièrement compactable.
|
||||
//
|
||||
// Seulement quand c'est nécessaire (AJEAN 0.17.4), sinon deux bugs :
|
||||
// - la queue contient déjà un message `user` : la dernière demande du torse a
|
||||
// DÉJÀ reçu sa réponse, la réinjecter juste avant la nouvelle faisait
|
||||
// répondre le modèle une seconde fois à une vieille question ;
|
||||
// - repli sans résumé (mid = torse dégraissé) : ce message y est déjà, il se
|
||||
// retrouvait en double, cette fois APRÈS ses propres résultats d'outils.
|
||||
tailHasUser := false
|
||||
for _, m := range msgs[tailStart:] {
|
||||
if m.Role == "user" {
|
||||
tailHasUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
summarized := err == nil && !summaryLooksEmpty(summary)
|
||||
var pending []Message
|
||||
for i := len(torso) - 1; i >= 0; i-- {
|
||||
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
|
||||
if torso[i].Role != "user" {
|
||||
continue
|
||||
}
|
||||
@@ -571,6 +597,20 @@ type summarizeResp struct {
|
||||
// Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle
|
||||
// principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte).
|
||||
func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
|
||||
return summarizeTranscriptFor(ctx, transcript, false)
|
||||
}
|
||||
|
||||
// codeSummaryRules : ce qu'un résumé de mode Code doit garder en plus — sans
|
||||
// quoi le builder réécrit des fichiers déjà faits et retombe dans des erreurs
|
||||
// déjà résolues (repris du COMPACTION_PROMPT d'OpenFox).
|
||||
const codeSummaryRules = `
|
||||
This is a CODING session. Also keep:
|
||||
- Every file created or modified (path) and what changed in it
|
||||
- Errors hit (build, tests, tools) and how each was resolved — or that it is still open
|
||||
- The commands that build and test the project`
|
||||
|
||||
// summarizeTranscriptFor : code = résumé d'une session du mode Code.
|
||||
func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) {
|
||||
sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
|
||||
|
||||
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
|
||||
@@ -583,6 +623,9 @@ Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
|
||||
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
|
||||
Write the summary in the SAME language as the conversation.`
|
||||
if code {
|
||||
sys += codeSummaryRules
|
||||
}
|
||||
|
||||
// Le résumé part au MÊME endroit que le chat : sur un preset externe, il
|
||||
// s'appuie sur l'API distante (backend_external.go). Le laisser taper le
|
||||
@@ -605,6 +648,11 @@ Write the summary in the SAME language as the conversation.`
|
||||
// passe ces kwargs au gabarit Jinja (--jinja).
|
||||
"chat_template_kwargs": map[string]any{"enable_thinking": false},
|
||||
}
|
||||
if ep.External {
|
||||
// Propre à llama.cpp : une API distante stricte (OpenAI) refuse un
|
||||
// argument inconnu par un 400, et la compaction échouait à chaque fois.
|
||||
delete(payload, "chat_template_kwargs")
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -649,3 +697,105 @@ Write the summary in the SAME language as the conversation.`
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// contextOverflow : le refus du moteur (corps d'erreur msg) signale-t-il un
|
||||
// débordement de la fenêtre de contexte ? Reconnaît les libellés de llama.cpp
|
||||
// (« exceeds the available context size », exceed_context_size_error) et des API
|
||||
// OpenAI-compatibles (context_length_exceeded, « maximum context length »). À
|
||||
// défaut de libellé reconnu, on se fie à la taille estimée : une conversation
|
||||
// déjà à 90 % de la fenêtre a toutes les chances d'être la cause.
|
||||
func contextOverflow(msg string, msgs []Message) bool {
|
||||
m := strings.ToLower(msg)
|
||||
for _, k := range []string{"context size", "context_size", "context length", "context_length",
|
||||
"context window", "n_ctx", "too many tokens", "prompt is too long", "exceeds the context"} {
|
||||
if strings.Contains(m, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return estimateTokens(msgs) >= int(float64(ctxWindow())*0.9)
|
||||
}
|
||||
|
||||
// ---- dernier recours : la requête ne rentre toujours pas -------------------
|
||||
// Repris d'AJEAN 0.17.5.
|
||||
|
||||
var reqTokensRe = regexp.MustCompile(`request \((\d+) tokens\)`)
|
||||
|
||||
// overflowTokens extrait la taille réelle de la requête refusée par llama.cpp
|
||||
// (« request (132291 tokens) exceeds… »), 0 si absente.
|
||||
func overflowTokens(msg string) int {
|
||||
if m := reqTokensRe.FindStringSubmatch(msg); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const shrinkTruncMarker = "\n[…tronqué : contexte plein. Relance l'outil de façon plus ciblée si tu as besoin de la suite]"
|
||||
|
||||
// shrinkToFit est le filet quand le moteur a refusé le prompt ET que le
|
||||
// compactage n'a rien pu faire (torse vide, ou réduction jugée trop faible :
|
||||
// typiquement un tour qui a lu beaucoup de gros fichiers d'un coup, tout est
|
||||
// dans la queue protégée). Avant, l'erreur 400 remontait telle quelle et la
|
||||
// conversation restait bloquée. Ici on réduit pour de bon :
|
||||
// 1. les gros résultats d'outils (du plus ancien au plus récent) sont tronqués ;
|
||||
// 2. si ça ne suffit pas, on retire les plus vieux échanges, par tour entier
|
||||
// (frontière `user`), en gardant les messages système de tête.
|
||||
//
|
||||
// La cible vise 60 % de la fenêtre, corrigée par l'écart entre estimation et
|
||||
// taille réelle (actual, lue dans l'erreur) : l'estimation sous-compte (images…).
|
||||
func shrinkToFit(msgs []Message, actual int) ([]Message, bool) {
|
||||
est := estimateTokens(msgs)
|
||||
if est <= 0 {
|
||||
return msgs, false
|
||||
}
|
||||
ratio := 1.0
|
||||
if actual > est {
|
||||
ratio = float64(actual) / float64(est)
|
||||
}
|
||||
target := int(float64(ctxWindow()) * 0.6 / ratio)
|
||||
out := append([]Message(nil), msgs...)
|
||||
for i := range out {
|
||||
if estimateTokens(out) <= target {
|
||||
break
|
||||
}
|
||||
if out[i].Role != "tool" {
|
||||
continue
|
||||
}
|
||||
if r := []rune(msgText(out[i])); len(r) > 2000 {
|
||||
out[i].Content = string(r[:1500]) + shrinkTruncMarker
|
||||
}
|
||||
}
|
||||
head := 0
|
||||
for head < len(out) && out[head].Role == "system" {
|
||||
head++
|
||||
}
|
||||
for estimateTokens(out) > target {
|
||||
next := -1
|
||||
for k := head + 1; k < len(out); k++ {
|
||||
if out[k].Role == "user" {
|
||||
next = k
|
||||
break
|
||||
}
|
||||
}
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
out = append(out[:head:head], out[next:]...)
|
||||
}
|
||||
return out, estimateTokens(out) < est
|
||||
}
|
||||
|
||||
// toolResultMax borne TOUT résultat d'outil dans la vue du modèle (≈ 8k
|
||||
// tokens). Le terminal et le web ont déjà leur propre plafond, plus bas ;
|
||||
// celui-ci rattrape les autres (lecture de fichier, pages mémoire, MCP…) : un
|
||||
// seul résultat géant suffisait à faire déborder une fenêtre de 65k d'un coup,
|
||||
// sans que le compactage puisse rien y faire. L'UI, elle, garde le résultat
|
||||
// complet (« voir plus », tool_results.go).
|
||||
const toolResultMax = 30000
|
||||
|
||||
func capToolResult(s string) string {
|
||||
if r := []rune(s); len(r) > toolResultMax {
|
||||
return string(r[:toolResultMax]) + "\n[…tronqué : résultat trop long. Cible une partie plus précise si tu as besoin de la suite]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le compactage de secours ne doit se déclencher que sur un vrai débordement de
|
||||
// contexte, pas sur n'importe quel refus du moteur.
|
||||
func TestContextOverflow(t *testing.T) {
|
||||
small := []Message{{Role: "user", Content: "salut"}}
|
||||
yes := []string{
|
||||
`{"error":{"code":400,"message":"request (70000 tokens) exceeds the available context size (65536 tokens), try increasing it","type":"exceed_context_size_error"}}`,
|
||||
`{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}`,
|
||||
}
|
||||
no := []string{
|
||||
`{"error":{"code":500,"message":"Failed to parse tool call arguments as JSON","type":"server_error"}}`,
|
||||
`{"error":{"code":503,"message":"Loading model","type":"unavailable_error"}}`,
|
||||
`Unable to generate parser for this template`,
|
||||
}
|
||||
for _, m := range yes {
|
||||
if !contextOverflow(m, small) {
|
||||
t.Errorf("débordement non reconnu : %s", m)
|
||||
}
|
||||
}
|
||||
for _, m := range no {
|
||||
if contextOverflow(m, small) {
|
||||
t.Errorf("faux débordement (déclencherait un compactage) : %s", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le dernier recours ramène sous la fenêtre même quand tout est dans un seul
|
||||
// tour (AJEAN 0.17.5).
|
||||
func TestShrinkToFit(t *testing.T) {
|
||||
testHome(t)
|
||||
big := strings.Repeat("x", 200000)
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "ctx"},
|
||||
{Role: "user", Content: "vieux"},
|
||||
{Role: "assistant", Content: "ok"},
|
||||
{Role: "user", Content: "lis tout"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "a"}}},
|
||||
{Role: "tool", ToolCallID: "a", Content: big},
|
||||
{Role: "tool", ToolCallID: "b", Content: big},
|
||||
}
|
||||
out, changed := shrinkToFit(msgs, 132291)
|
||||
if !changed || out[0].Role != "system" {
|
||||
t.Fatalf("pas réduit : %v", changed)
|
||||
}
|
||||
if est := estimateTokens(out); est > int(float64(ctxWindow())*0.6) {
|
||||
t.Fatalf("encore trop gros : %d", est)
|
||||
}
|
||||
if overflowTokens(`{"message":"request (132291 tokens) exceeds"}`) != 132291 {
|
||||
t.Fatal("taille réelle non lue")
|
||||
}
|
||||
}
|
||||
|
||||
// Un résultat d'outil géant est borné pour le modèle, avec une mention qui dit
|
||||
// au modèle de cibler plus précisément.
|
||||
func TestCapToolResult(t *testing.T) {
|
||||
if got := capToolResult("court"); got != "court" {
|
||||
t.Fatalf("résultat court modifié : %q", got)
|
||||
}
|
||||
got := capToolResult(strings.Repeat("é", toolResultMax+500))
|
||||
if r := []rune(got); len(r) > toolResultMax+200 || !strings.Contains(got, "tronqué") {
|
||||
t.Fatalf("résultat géant mal borné (%d runes)", len(r))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -177,3 +181,74 @@ func TestEstimateTokensGrows(t *testing.T) {
|
||||
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
|
||||
}
|
||||
}
|
||||
|
||||
// Repli sans résumé (moteur absent) : la demande en cours est déjà dans le
|
||||
// torse dégraissé, elle ne doit pas être réinjectée une seconde fois — elle
|
||||
// apparaissait APRÈS ses propres résultats d'outils (AJEAN 0.17.4).
|
||||
func TestCompactSansResumeNeDoublePasLaDemande(t *testing.T) {
|
||||
testHome(t)
|
||||
page := func(n int) Message {
|
||||
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
||||
}
|
||||
msgs := []Message{
|
||||
um("première question de la conversation"), am("ok"),
|
||||
um("cherche les horaires du train pour Lyon"),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), page(i))
|
||||
}
|
||||
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
||||
n := 0
|
||||
for _, m := range out {
|
||||
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("demande en cours présente %d fois après compactage, attendu 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode Code : le résumé demande les fichiers touchés et les erreurs, et l'état
|
||||
// des critères est rendu au builder après compactage.
|
||||
func TestCompactageModeCodeGardeLEtat(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"les tests passent"}}, false)
|
||||
var sys string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
sys = msgText(body.Messages[0])
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- main.go modifié : la fonction parse corrigée, go build et go test passent"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := func(n int) Message {
|
||||
return tm("sortie de commande " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
||||
}
|
||||
msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("bash"), page(i))
|
||||
}
|
||||
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true, Code: true})
|
||||
if !changed {
|
||||
t.Fatal("pas compacté")
|
||||
}
|
||||
if !strings.Contains(sys, "CODING session") {
|
||||
t.Fatal("consigne de résumé du mode Code absente")
|
||||
}
|
||||
found := false
|
||||
for _, m := range out {
|
||||
if strings.Contains(msgText(m), "Acceptance criteria (current state)") && strings.Contains(msgText(m), "les tests passent") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("critères non rendus après compactage")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package loki
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -68,6 +69,27 @@ type Conversation struct {
|
||||
// à une génération fantôme dans la discussion ouverte.
|
||||
runningTaskID string
|
||||
runningTaskName string
|
||||
|
||||
// File d'attente des messages envoyés PENDANT une génération (AJEAN
|
||||
// 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière
|
||||
// d'étape (après un appel d'outil), ou — si le tour se termine avant —
|
||||
// traités comme tours suivants, dans l'ordre. Protégée par mu.
|
||||
queued []queuedMsg
|
||||
// recentCIDs : identifiants d'envoi déjà acceptés. L'UI réessaie un envoi
|
||||
// dont la réponse s'est perdue (tunnel) : avant la file, le 409 « déjà en
|
||||
// cours » faisait office de dédoublonnage ; sans lui, le réessai mettrait
|
||||
// le même message deux fois en file.
|
||||
recentCIDs []string
|
||||
}
|
||||
|
||||
// queuedMsg = un message mis en file pendant la génération. Caps et
|
||||
// température sont ceux choisis à l'envoi : un tour démarré depuis la file doit
|
||||
// s'exécuter avec ces réglages-là.
|
||||
type queuedMsg struct {
|
||||
text string
|
||||
files []attachInfo
|
||||
caps Caps
|
||||
temp float64
|
||||
}
|
||||
|
||||
var conv = func() *Conversation {
|
||||
@@ -153,6 +175,7 @@ func LoadConversation() {
|
||||
// Guérit les conversations d'avant le passage des captures en éphémère : un
|
||||
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
|
||||
conv.Messages = stripImageParts(conv.Messages)
|
||||
go pruneChatImages()
|
||||
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
|
||||
conv.Generating = false
|
||||
conv.cancel = nil
|
||||
@@ -166,10 +189,12 @@ func (c *Conversation) loadFrom(b []byte) {
|
||||
c.Stop()
|
||||
c.mu.Lock()
|
||||
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
|
||||
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
|
||||
if len(b) > 0 {
|
||||
_ = json.Unmarshal(b, c)
|
||||
c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement
|
||||
}
|
||||
go pruneChatImages() // plus rien ne référence les images de l'ancien fil
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
c.epoch++
|
||||
@@ -195,6 +220,9 @@ func reloadEncryptedStores() {
|
||||
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) persist() {
|
||||
c.mu.Lock()
|
||||
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
|
||||
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
|
||||
refImagesInMessages(c.Messages)
|
||||
b, err := json.Marshal(c)
|
||||
title := convSummary(c.Messages)
|
||||
turns := 0
|
||||
@@ -358,6 +386,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
c.appendDelta(epoch, map[string]any{"compact_noop": true})
|
||||
return msgs, false
|
||||
}
|
||||
// Les pages mémoire lues ont été résumées avec le torse : un rappel liste
|
||||
// leurs noms pour que le modèle les relise au besoin (AJEAN 0.14.0). Agent
|
||||
// seulement — sans lui, pas d'outil mem_read pour y donner suite.
|
||||
if caps.Agent {
|
||||
compacted = remindReadMemPages(compacted, msgs)
|
||||
}
|
||||
overhead := ctxUsed - estimateTokens(msgs)
|
||||
if overhead < 0 {
|
||||
overhead = 0
|
||||
@@ -495,10 +529,154 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrDupSend : cet envoi (même cid) a déjà été accepté — réessai d'un client
|
||||
// qui n'a pas reçu la réponse. Ce n'est pas une erreur pour l'utilisateur.
|
||||
var ErrDupSend = fmt.Errorf("envoi déjà reçu")
|
||||
|
||||
// seenCID enregistre cid et dit s'il avait déjà été vu. Appelant : mu tenu.
|
||||
func (c *Conversation) seenCIDLocked(cid string) bool {
|
||||
if cid == "" {
|
||||
return false
|
||||
}
|
||||
for _, v := range c.recentCIDs {
|
||||
if v == cid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
c.recentCIDs = append(c.recentCIDs, cid)
|
||||
if len(c.recentCIDs) > 64 {
|
||||
c.recentCIDs = c.recentCIDs[len(c.recentCIDs)-64:]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnqueueOrStart démarre un tour tout de suite si le moteur est libre, sinon
|
||||
// MET EN FILE le message (AJEAN 0.14.0) — au lieu du 409 d'avant, qui obligeait
|
||||
// à arrêter la réponse pour ajouter une précision. Seul un tour de CHAT accepte
|
||||
// une file : une tâche planifiée qui occupe le modèle garde le refus
|
||||
// (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir
|
||||
// recentCIDs) ; un doublon renvoie ErrDupSend.
|
||||
func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps Caps, temperature float64) (bool, error) {
|
||||
c.mu.Lock()
|
||||
if c.seenCIDLocked(cid) {
|
||||
c.mu.Unlock()
|
||||
return false, ErrDupSend
|
||||
}
|
||||
if c.Generating && c.runningTaskName == "" {
|
||||
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
|
||||
c.mu.Unlock()
|
||||
return true, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
err := c.StartTurn(text, files, caps, temperature)
|
||||
if errors.Is(err, ErrBusy) {
|
||||
// Un autre tour a démarré entre le test ci-dessus et StartTurn (deux
|
||||
// appareils qui envoient en même temps) : en file plutôt qu'un refus
|
||||
// (AJEAN 0.17.4). Une tâche planifiée garde le refus : sa fin ne dépile rien.
|
||||
c.mu.Lock()
|
||||
if c.Generating && c.runningTaskName == "" {
|
||||
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
|
||||
c.mu.Unlock()
|
||||
// Ce tour a pu finir entre-temps : sans relance, le message attendrait
|
||||
// le prochain envoi.
|
||||
c.startQueuedIfAny()
|
||||
return true, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
// Refusé (modèle pas prêt, tâche en cours) : le client pourra renvoyer
|
||||
// ce même message, son cid ne doit pas rester marqué comme reçu.
|
||||
c.mu.Lock()
|
||||
for i, v := range c.recentCIDs {
|
||||
if v == cid {
|
||||
c.recentCIDs = append(c.recentCIDs[:i], c.recentCIDs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// drainQueued vide la file et renvoie les messages utilisateur à injecter dans
|
||||
// le tour EN COURS (vue modèle), en les journalisant pour qu'ils apparaissent
|
||||
// dans le fil sur tous les appareils. Appelé par runChat entre deux étapes.
|
||||
// epoch garde contre un reset survenu entre-temps.
|
||||
func (c *Conversation) drainQueued(epoch int) []Message {
|
||||
c.mu.Lock()
|
||||
if c.epoch != epoch || len(c.queued) == 0 {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
items := c.queued
|
||||
c.queued = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
var out []Message
|
||||
for _, q := range items {
|
||||
delta := map[string]any{"user": q.text}
|
||||
if m := chatModelName(); m != "" {
|
||||
delta["model"] = m
|
||||
}
|
||||
if len(q.files) > 0 {
|
||||
delta["files"] = q.files
|
||||
}
|
||||
c.appendDelta(epoch, delta)
|
||||
prompt := q.text
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "Prends-en connaissance."
|
||||
}
|
||||
out = append(out, Message{Role: "user", Content: userMessageContent(q.files, prompt)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// startQueuedIfAny démarre le prochain tour depuis la file, s'il en reste et
|
||||
// que le moteur est libre. Appelé à la toute fin d'un tour : les messages
|
||||
// envoyés après la dernière frontière d'étape sont ainsi traités sans que
|
||||
// l'utilisateur ait à les renvoyer. Chaque tour lancé ainsi rappelle
|
||||
// startQueuedIfAny à sa fin → la file se vide dans l'ordre. Renvoie true si un
|
||||
// tour a démarré.
|
||||
func (c *Conversation) startQueuedIfAny() bool {
|
||||
c.mu.Lock()
|
||||
if c.Generating || len(c.queued) == 0 {
|
||||
c.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
q := c.queued[0]
|
||||
c.queued = c.queued[1:]
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
if err := c.StartTurn(q.text, q.files, q.caps, q.temp); err != nil {
|
||||
// Modèle indisponible au moment de dépiler (rare) : on le dit dans le
|
||||
// fil plutôt que de perdre le message en silence, et on tente le suivant.
|
||||
c.appendDelta(epoch, map[string]any{"error": "message en attente non envoyé : " + err.Error()})
|
||||
return c.startQueuedIfAny()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dropQueued abandonne la file (bouton stop : l'utilisateur reprend la main) et
|
||||
// le signale au client, qui retire ses messages « en attente ».
|
||||
func (c *Conversation) dropQueued(epoch int) {
|
||||
c.mu.Lock()
|
||||
n := len(c.queued)
|
||||
c.queued = nil
|
||||
c.mu.Unlock()
|
||||
if n > 0 {
|
||||
c.appendDelta(epoch, map[string]any{"queue_dropped": n})
|
||||
}
|
||||
}
|
||||
|
||||
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
|
||||
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
|
||||
func chatModelName() string {
|
||||
m := strings.TrimSpace(ReadConfig()["MODEL"])
|
||||
cfg := ReadConfig()
|
||||
if isExternalConfig(cfg) {
|
||||
return strings.TrimSpace(cfg[extKeyModel]) // modèle distant, tel quel
|
||||
}
|
||||
m := strings.TrimSpace(cfg["MODEL"])
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -539,15 +717,24 @@ 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
|
||||
// File d'attente : interrompu par un stop, l'utilisateur reprend la main
|
||||
// et les messages en file sont abandonnés. Sinon on démarre le prochain
|
||||
// tour depuis la file — ceux qui n'ont pas pu être injectés en cours de
|
||||
// route sont traités, dans l'ordre.
|
||||
if ctx.Err() != nil {
|
||||
c.dropQueued(epoch)
|
||||
return
|
||||
}
|
||||
// Notification Web Push, seulement quand plus rien ne suit : un message
|
||||
// en file qui démarre aussitôt n'est pas une « réponse prête », et sa
|
||||
// propre fin notifiera. 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 {
|
||||
// (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
|
||||
// de notification après un « stop » (retour ci-dessus) : l'utilisateur
|
||||
// est là et a coupé volontairement.
|
||||
if !c.startQueuedIfAny() && hasPushSubs() {
|
||||
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
|
||||
}
|
||||
}()
|
||||
@@ -574,9 +761,20 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// Contexte du projet actif (description, index mémoire, index des trackers),
|
||||
// même traitement : injecté dans la vue envoyée, jamais persisté — voir
|
||||
// projectSystemMessages.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
//
|
||||
// Mode agent OFF = modèle BRUT (AJEAN 0.13.13) : ni l'un ni l'autre. Sans
|
||||
// agent, on veut parler au modèle nu, comme à un llama-server direct. Le
|
||||
// prompt du preset et l'index mémoire décrivent des outils que ce mode n'a
|
||||
// pas : le modèle se mettait à écrire des <tool_call> en clair.
|
||||
final := msgs
|
||||
if caps.Agent {
|
||||
final = append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
}
|
||||
|
||||
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
|
||||
@@ -590,25 +788,23 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
|
||||
// déjà la compaction), approximative mais jamais absente.
|
||||
sawUsage := false
|
||||
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
|
||||
// pas de vérification du mode Code avant sa réponse.
|
||||
asked := false
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
case ev.ToolUsed != nil:
|
||||
// Le texte écrit AVANT cet appel d'outil est déjà rangé dans le message
|
||||
// assistant porteur des tool_calls (extra). Le garder aussi dans la
|
||||
// réponse finale le doublait dans l'historique vu par le modèle — du
|
||||
// contexte gaspillé à chaque tour d'outil (AJEAN 0.17.4).
|
||||
content.Reset()
|
||||
tu := map[string]any{
|
||||
"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 != "" {
|
||||
@@ -618,10 +814,19 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// pour que le diff soit encore là après un rafraîchissement.
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
tu["added"] = ev.ToolUsed.Added
|
||||
tu["removed"] = ev.ToolUsed.Removed
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction faite en cours de tour : elle remplace la base au lieu de
|
||||
@@ -652,6 +857,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.Ask != nil:
|
||||
// Question structurée (outil ask) : carte à boutons dans l'UI.
|
||||
asked = true
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
@@ -662,7 +868,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.appendDelta(epoch, map[string]any{"content": ev.Content})
|
||||
}
|
||||
return true // génération détachée : on ne s'interrompt jamais sur un abonné
|
||||
})
|
||||
}, func() []Message { return c.drainQueued(epoch) })
|
||||
|
||||
// Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats)
|
||||
// PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle
|
||||
@@ -701,7 +907,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// corrections, re-vérification — AVANT de rendre la main. Voir
|
||||
// code_verify.go. No-op hors mode code ou sans critères.
|
||||
if !stale && ctx.Err() == nil {
|
||||
c.codeVerifyLoop(ctx, caps, temperature, epoch)
|
||||
c.codeVerifyLoop(ctx, caps, temperature, epoch, asked)
|
||||
c.mu.Lock()
|
||||
msgs = append([]Message(nil), c.Messages...)
|
||||
ctxUsed = c.CtxUsed
|
||||
@@ -803,6 +1009,7 @@ func (c *Conversation) Reset() {
|
||||
c.epoch++
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
@@ -948,22 +1155,76 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe diffuse les événements au client via emit : d'abord un REPLAY coalescé
|
||||
// historyTailDefault : échanges rejoués à l'ouverture d'une session (reset) pour
|
||||
// un client qui gère la pagination de l'historique.
|
||||
const historyTailDefault = 20
|
||||
|
||||
// historyCut calcule où couper le rejeu pour ne garder que les `tail` derniers
|
||||
// échanges (un échange commence à un événement `user`). Renvoie le Seq à partir
|
||||
// duquel rejouer (exclu) et le nombre d'échanges masqués ; (0, 0) = tout
|
||||
// rejouer. Repris d'AJEAN 0.15.7.
|
||||
func historyCut(log []LogEvent, tail int) (int, int) {
|
||||
if tail <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
seen := 0
|
||||
for i := len(log) - 1; i >= 0; i-- {
|
||||
if _, ok := log[i].Delta["user"]; !ok {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if seen == tail {
|
||||
hidden := 0
|
||||
for _, ev := range log[:i] {
|
||||
if _, ok := ev.Delta["user"]; ok {
|
||||
hidden++
|
||||
}
|
||||
}
|
||||
if hidden == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
return log[i].Seq - 1, hidden
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// historyHead : événements qui annoncent un historique tronqué. Les états que
|
||||
// les échanges masqués avaient posés et que le client garde (mode Chat/Code,
|
||||
// critères du mode Code) sont réémis tels qu'ils étaient à la coupe — sans eux,
|
||||
// une discussion en mode Code rouverte s'afficherait en mode Chat.
|
||||
func historyHead(log []LogEvent, cut, hidden int) []map[string]any {
|
||||
out := []map[string]any{{"history_more": hidden}}
|
||||
var mode, crit map[string]any
|
||||
for _, ev := range log {
|
||||
if ev.Seq > cut {
|
||||
break
|
||||
}
|
||||
if _, ok := ev.Delta["mode"]; ok {
|
||||
mode = ev.Delta
|
||||
}
|
||||
if _, ok := ev.Delta["criteria"]; ok {
|
||||
crit = ev.Delta
|
||||
}
|
||||
}
|
||||
for _, d := range []map[string]any{mode, crit} {
|
||||
if d != nil {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe : abonnement sans pagination (rejeu complet).
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
|
||||
c.SubscribeTail(ctx, from, -1, "", emit)
|
||||
}
|
||||
|
||||
// SubscribeTail diffuse les événements au client via emit : d'abord un REPLAY coalescé
|
||||
// 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).
|
||||
// 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) {
|
||||
func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, convID string, emit func(map[string]any) bool) {
|
||||
// Réveille les attentes de cond quand la connexion se ferme.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
@@ -995,23 +1256,42 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit
|
||||
// la conversation ouverte apparaît tronquée, voire vide. En session normale, le
|
||||
// client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il
|
||||
// l'est, son curseur vient d'une autre session → on repart du début.
|
||||
//
|
||||
// ⚠️ Même famille, cas plus sournois (AJEAN 0.14.0) : un AUTRE appareil a
|
||||
// changé de discussion pendant que ce client était déconnecté, et son `from`
|
||||
// est PLUS BAS que le dernier Seq de la nouvelle. Le rejeu greffait alors la
|
||||
// fin de la nouvelle discussion sur le début de l'ancienne restée à l'écran —
|
||||
// deux fils fusionnés jusqu'au prochain rafraîchissement. Le client renvoie
|
||||
// l'id de la discussion qu'il affiche (convID) : s'il ne correspond plus, on
|
||||
// lui ordonne d'abord de vider l'écran (reset), puis on rejoue tout.
|
||||
curID := getStr(bkChat, ckActive)
|
||||
staleConv := from > 0 && convID != "" && convID != curID
|
||||
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
|
||||
from = 0
|
||||
staleConv = staleConv || convID != ""
|
||||
}
|
||||
last := 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}) {
|
||||
if staleConv {
|
||||
from = 0
|
||||
if !emit(map[string]any{"reset": true, "id": curID}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, ev := range events {
|
||||
// Chargement initial d'un client paginé : on ne rejoue que la fin du fil,
|
||||
// précédée de {history_more: N}. Sur un long fil d'agent, tout rejouer
|
||||
// envoyait des Mo et bâtissait des milliers de bulles avant d'afficher quoi
|
||||
// que ce soit.
|
||||
if from == 0 && tail > 0 {
|
||||
if cut, hidden := historyCut(snapshot, tail); hidden > 0 {
|
||||
from = cut
|
||||
for _, ev := range historyHead(snapshot, cut, hidden) {
|
||||
if !emit(ev) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
last := from
|
||||
for _, ev := range coalesceReplay(snapshot, from) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -1022,7 +1302,7 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit
|
||||
last = s
|
||||
}
|
||||
}
|
||||
if !emit(map[string]any{"caught_up": true}) {
|
||||
if !emit(map[string]any{"caught_up": true, "id": curID}) {
|
||||
return
|
||||
}
|
||||
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
|
||||
@@ -1055,10 +1335,27 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit
|
||||
if c.epoch != epoch { // reset → on ordonne au client de nettoyer et on repart
|
||||
epoch = c.epoch
|
||||
last = 0
|
||||
// Session ouverte (fil complet qui suit) chez un client paginé : on
|
||||
// saute directement aux derniers échanges.
|
||||
var head []map[string]any
|
||||
if tail >= 0 {
|
||||
if cut, hidden := historyCut(c.Log, historyTailDefault); hidden > 0 {
|
||||
last = cut
|
||||
// ctx_used vit dans le journal des échanges masqués : on le
|
||||
// donne tel qu'il est maintenant, sinon la jauge resterait à 0.
|
||||
head = append(historyHead(c.Log, cut, hidden), map[string]any{"ctx_used": c.CtxUsed})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if !emit(map[string]any{"reset": true}) {
|
||||
// id : la discussion désormais affichée (lue hors verrou — base).
|
||||
if !emit(map[string]any{"reset": true, "id": getStr(bkChat, ckActive)}) {
|
||||
return
|
||||
}
|
||||
for _, ev := range head {
|
||||
if !emit(ev) {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, 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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 1, 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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, 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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, tc.from, 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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 502, 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, false, func(ev map[string]any) bool {
|
||||
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
|
||||
got = append(got, ev)
|
||||
if ev["caught_up"] != nil {
|
||||
cancel()
|
||||
@@ -309,50 +309,3 @@ 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)
|
||||
}
|
||||
}
|
||||
+83
-24
@@ -5,12 +5,18 @@ package loki
|
||||
// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
|
||||
// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
|
||||
// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
|
||||
//
|
||||
// Les lignes envoyées sont bornées (diffMaxShown), mais les compteurs +N / -N
|
||||
// sont calculés AVANT la coupe et renvoyés à part : l'UI les comptait dans le
|
||||
// diff tronqué, d'où un « +500 » pendant l'écriture qui retombait à « +120 ».
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent)
|
||||
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
|
||||
diffMaxLines = 400 // au-delà (partie modifiée), on compare sans détail (LCS trop lente)
|
||||
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
|
||||
diffContext = 3 // lignes identiques gardées autour d'un changement
|
||||
diffOmitPrefix = "…"
|
||||
)
|
||||
|
||||
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
|
||||
@@ -19,26 +25,69 @@ type DiffLine struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les
|
||||
// lignes communes comme contexte : c'est ce qui rend le changement lisible
|
||||
// quand l'IA ne touche qu'un mot au milieu d'un paragraphe.
|
||||
func lineDiff(oldText, newText string) []DiffLine {
|
||||
// lineDiff compare deux blocs de texte ligne à ligne et renvoie les lignes à
|
||||
// afficher (bornées) avec le nombre RÉEL de lignes ajoutées et retirées.
|
||||
//
|
||||
// Les lignes communes en tête et en queue sont écartées avant la LCS : un
|
||||
// changement d'une ligne dans un gros bloc reste un +1 -1 lisible (au lieu d'un
|
||||
// « tout retiré, tout ajouté ») et le changement n'est pas repoussé hors des
|
||||
// lignes affichées par des centaines de lignes de contexte.
|
||||
func lineDiff(oldText, newText string) (lines []DiffLine, added, removed int) {
|
||||
a := splitLines(oldText)
|
||||
b := splitLines(newText)
|
||||
// Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre
|
||||
// simplement l'ancien en retrait et le nouveau en ajout.
|
||||
if len(a) > diffMaxLines || len(b) > diffMaxLines {
|
||||
out := make([]DiffLine, 0, len(a)+len(b))
|
||||
for _, l := range a {
|
||||
out = append(out, DiffLine{Op: "-", Text: l})
|
||||
pre := 0
|
||||
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
|
||||
pre++
|
||||
}
|
||||
suf := 0
|
||||
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
|
||||
suf++
|
||||
}
|
||||
ma, mb := a[pre:len(a)-suf], b[pre:len(b)-suf]
|
||||
|
||||
var mid []DiffLine
|
||||
if len(ma) > diffMaxLines || len(mb) > diffMaxLines {
|
||||
// Partie modifiée énorme : pas de LCS (coût quadratique), l'ancien en
|
||||
// retrait puis le nouveau en ajout.
|
||||
mid = make([]DiffLine, 0, len(ma)+len(mb))
|
||||
for _, l := range ma {
|
||||
mid = append(mid, DiffLine{Op: "-", Text: l})
|
||||
}
|
||||
for _, l := range b {
|
||||
out = append(out, DiffLine{Op: "+", Text: l})
|
||||
for _, l := range mb {
|
||||
mid = append(mid, DiffLine{Op: "+", Text: l})
|
||||
}
|
||||
} else {
|
||||
mid = lcsDiff(ma, mb)
|
||||
}
|
||||
for _, d := range mid {
|
||||
switch d.Op {
|
||||
case "+":
|
||||
added++
|
||||
case "-":
|
||||
removed++
|
||||
}
|
||||
return capLines(out)
|
||||
}
|
||||
|
||||
// LCS : table des longueurs, puis remontée.
|
||||
// Contexte autour du changement : quelques lignes, le reste résumé.
|
||||
var out []DiffLine
|
||||
if pre > diffContext {
|
||||
out = append(out, DiffLine{Op: " ", Text: omitted(pre - diffContext)})
|
||||
}
|
||||
for _, l := range a[max(0, pre-diffContext):pre] {
|
||||
out = append(out, DiffLine{Op: " ", Text: l})
|
||||
}
|
||||
out = append(out, mid...)
|
||||
for _, l := range a[len(a)-suf : len(a)-suf+min(suf, diffContext)] {
|
||||
out = append(out, DiffLine{Op: " ", Text: l})
|
||||
}
|
||||
if suf > diffContext {
|
||||
out = append(out, DiffLine{Op: " ", Text: omitted(suf - diffContext)})
|
||||
}
|
||||
return capLines(out), added, removed
|
||||
}
|
||||
|
||||
// lcsDiff : diff exact par plus longue sous-séquence commune.
|
||||
func lcsDiff(a, b []string) []DiffLine {
|
||||
n, m := len(a), len(b)
|
||||
lcs := make([][]int, n+1)
|
||||
for i := range lcs {
|
||||
@@ -77,27 +126,37 @@ func lineDiff(oldText, newText string) []DiffLine {
|
||||
for ; j < m; j++ {
|
||||
out = append(out, DiffLine{Op: "+", Text: b[j]})
|
||||
}
|
||||
return capLines(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// addedDiff présente un contenu entièrement nouveau (création d'une page).
|
||||
func addedDiff(text string) []DiffLine {
|
||||
lines := splitLines(text)
|
||||
out := make([]DiffLine, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
// addedDiff présente un contenu entièrement nouveau (création d'une page ou
|
||||
// d'un fichier) : toutes ses lignes en ajout.
|
||||
func addedDiff(text string) (lines []DiffLine, added int) {
|
||||
ls := splitLines(text)
|
||||
out := make([]DiffLine, 0, len(ls))
|
||||
for _, l := range ls {
|
||||
out = append(out, DiffLine{Op: "+", Text: l})
|
||||
}
|
||||
return capLines(out)
|
||||
return capLines(out), len(ls)
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
|
||||
s = strings.TrimSuffix(s, "\n")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func omitted(n int) string {
|
||||
if n == 1 {
|
||||
return diffOmitPrefix + "(1 ligne identique)"
|
||||
}
|
||||
return diffOmitPrefix + "(" + itoa(n) + " lignes identiques)"
|
||||
}
|
||||
|
||||
// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
|
||||
func capLines(d []DiffLine) []DiffLine {
|
||||
if len(d) <= diffMaxShown {
|
||||
@@ -105,7 +164,7 @@ func capLines(d []DiffLine) []DiffLine {
|
||||
}
|
||||
cut := len(d) - diffMaxShown
|
||||
out := append([]DiffLine{}, d[:diffMaxShown]...)
|
||||
return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
|
||||
return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"})
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func numbered(n int, suffix string) string {
|
||||
var b strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
b.WriteString("ligne " + itoa(i) + suffix + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Un fichier de 500 lignes : +500, même si l'UI n'en reçoit que 120.
|
||||
func TestAddedDiffCountsBeyondCap(t *testing.T) {
|
||||
lines, added := addedDiff(numbered(500, ""))
|
||||
if added != 500 {
|
||||
t.Fatalf("added = %d, attendu 500", added)
|
||||
}
|
||||
if len(lines) > diffMaxShown+1 {
|
||||
t.Fatalf("%d lignes envoyées, plafond %d", len(lines), diffMaxShown)
|
||||
}
|
||||
}
|
||||
|
||||
// Le saut de ligne final ne compte pas pour une ligne.
|
||||
func TestAddedDiffTrailingNewline(t *testing.T) {
|
||||
if _, n := addedDiff("a\nb\n"); n != 2 {
|
||||
t.Fatalf("n = %d, attendu 2", n)
|
||||
}
|
||||
if _, n := addedDiff("a\nb"); n != 2 {
|
||||
t.Fatalf("n = %d, attendu 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Une ligne changée au milieu d'un gros bloc : +1 -1, et le changement est
|
||||
// bien dans les lignes affichées (pas noyé sous le contexte).
|
||||
func TestLineDiffSmallChangeInBigBlock(t *testing.T) {
|
||||
old := numbered(800, "")
|
||||
neu := strings.Replace(old, "ligne 600\n", "ligne 600 modifiée\n", 1)
|
||||
lines, add, del := lineDiff(old, neu)
|
||||
if add != 1 || del != 1 {
|
||||
t.Fatalf("+%d -%d, attendu +1 -1", add, del)
|
||||
}
|
||||
var sawAdd, sawDel bool
|
||||
for _, l := range lines {
|
||||
sawAdd = sawAdd || (l.Op == "+" && l.Text == "ligne 600 modifiée")
|
||||
sawDel = sawDel || (l.Op == "-" && l.Text == "ligne 600")
|
||||
}
|
||||
if !sawAdd || !sawDel {
|
||||
t.Fatalf("le changement n'est pas dans le diff affiché : %v", lines)
|
||||
}
|
||||
if len(lines) > 2*diffContext+4 {
|
||||
t.Fatalf("trop de contexte : %d lignes", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineDiffCountsAreExact(t *testing.T) {
|
||||
_, add, del := lineDiff("a\nb\nc\n", "a\nx\ny\nc\n")
|
||||
if add != 2 || del != 1 {
|
||||
t.Fatalf("+%d -%d, attendu +2 -1", add, del)
|
||||
}
|
||||
_, add, del = lineDiff(numbered(300, ""), numbered(300, " bis"))
|
||||
if add != 300 || del != 300 {
|
||||
t.Fatalf("+%d -%d, attendu +300 -300", add, del)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// historyCut ne garde que les N derniers échanges (un échange commence à `user`).
|
||||
func TestHistoryCut(t *testing.T) {
|
||||
var log []LogEvent
|
||||
seq := 0
|
||||
add := func(d map[string]any) { seq++; log = append(log, LogEvent{Seq: seq, Delta: d}) }
|
||||
for i := 0; i < 5; i++ {
|
||||
add(map[string]any{"user": "q"})
|
||||
add(map[string]any{"content": "r"})
|
||||
add(map[string]any{"turn_done": true})
|
||||
}
|
||||
// 5 échanges, seq 1..15 ; les 2 derniers commencent aux seq 10 et 13.
|
||||
if cut, hidden := historyCut(log, 2); cut != 9 || hidden != 3 {
|
||||
t.Fatalf("tail=2 : cut=%d hidden=%d, attendu 9 et 3", cut, hidden)
|
||||
}
|
||||
if cut, hidden := historyCut(log, 5); cut != 0 || hidden != 0 {
|
||||
t.Fatalf("tail=5 (tout tient) : cut=%d hidden=%d, attendu 0 et 0", cut, hidden)
|
||||
}
|
||||
if cut, hidden := historyCut(log, 0); cut != 0 || hidden != 0 {
|
||||
t.Fatalf("tail=0 : pas de coupure attendue (cut=%d hidden=%d)", cut, hidden)
|
||||
}
|
||||
// Les événements rejoués après la coupure commencent bien au 4e échange.
|
||||
evs := coalesceReplay(log, 9)
|
||||
if u, _ := evs[0]["user"].(string); u != "q" || evs[0]["seq"] != 10 {
|
||||
t.Fatalf("premier événement rejoué inattendu : %v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// La coupe réémet le mode (Chat/Code) posé dans la partie masquée.
|
||||
func TestHistoryHeadCarriesMode(t *testing.T) {
|
||||
log := []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"mode": "code"}},
|
||||
{Seq: 2, Delta: map[string]any{"user": "q1"}},
|
||||
{Seq: 3, Delta: map[string]any{"user": "q2"}},
|
||||
}
|
||||
head := historyHead(log, 2, 1)
|
||||
if len(head) != 2 || head[0]["history_more"] != 1 || head[1]["mode"] != "code" {
|
||||
t.Fatalf("en-tête inattendu : %v", head)
|
||||
}
|
||||
}
|
||||
|
||||
// Un client paginé ne reçoit au chargement que les derniers échanges, précédés
|
||||
// de {history_more} ; un ancien client (tail < 0) reçoit tout.
|
||||
func TestSubscribeTailPaginates(t *testing.T) {
|
||||
c := newTestConv()
|
||||
for i := 0; i < 5; i++ {
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "q"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "r"})
|
||||
}
|
||||
collect := func(tail int) (more any, users int) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.SubscribeTail(ctx, 0, tail, "", func(m map[string]any) bool {
|
||||
if v, ok := m["history_more"]; ok {
|
||||
more = v
|
||||
}
|
||||
if _, ok := m["user"]; ok {
|
||||
users++
|
||||
}
|
||||
if _, ok := m["caught_up"]; ok {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
||||
if more, users := collect(2); more != 3 || users != 2 {
|
||||
t.Fatalf("tail=2 : history_more=%v, %d échanges rejoués (attendu 3 et 2)", more, users)
|
||||
}
|
||||
if more, users := collect(-1); more != nil || users != 5 {
|
||||
t.Fatalf("ancien client : history_more=%v, %d échanges (attendu rien et 5)", more, users)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chat_images.go — images d'une conversation stockées PAR RÉFÉRENCE.
|
||||
//
|
||||
// Une image vue par le modèle (pièce jointe, see_image, capture du navigateur) est
|
||||
// une partie `image_url` en base64 dans un message. Gardée telle quelle dans
|
||||
// l'historique, elle y pesait des Mo (une conversation de 14 messages : 36 Mo), et
|
||||
// l'historique entier est réécrit à chaque fin de tour et relu à chaque ouverture.
|
||||
//
|
||||
// On la range donc UNE fois sur disque, sous LOKI_HOME/chatimg/<empreinte>.<ext>
|
||||
// (hors du dossier de travail, que l'IA peut vider), et l'historique n'en garde
|
||||
// que l'adresse « loki-img:<nom> ». Juste avant l'envoi au modèle,
|
||||
// expandImageRefs remet les octets exacts (cache mémoire) : pour le modèle, rien
|
||||
// ne change. Mêmes octets, même empreinte → aucune copie en double.
|
||||
|
||||
const imgRefScheme = "loki-img:"
|
||||
|
||||
func chatImgDir() string { return filepath.Join(LokiHome(), "chatimg") }
|
||||
|
||||
var imgExtByMime = map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"}
|
||||
|
||||
// storeChatImage range les octets d'une image et renvoie sa référence.
|
||||
func storeChatImage(b []byte, mime string) (string, error) {
|
||||
ext := imgExtByMime[mime]
|
||||
if ext == "" {
|
||||
ext = ".img"
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
name := hex.EncodeToString(sum[:16]) + ext
|
||||
dir := chatImgDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
// Chiffrée comme le fil quand la mémoire l'est (même enveloppe que les
|
||||
// pages). Verrouillée : refus, l'image reste en base64 dans le message.
|
||||
enc, err := encodeMemContent(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return imgRefScheme + name, nil
|
||||
}
|
||||
|
||||
// Cache des data-URL reconstituées : un tour d'agent renvoie tout l'historique à
|
||||
// CHAQUE étape, on ne relit pas le disque à chaque fois. Borné en taille.
|
||||
var (
|
||||
imgCacheMu sync.Mutex
|
||||
imgCache = map[string]string{}
|
||||
imgCacheOrder []string
|
||||
imgCacheBytes int
|
||||
)
|
||||
|
||||
const imgCacheMax = 64 << 20
|
||||
|
||||
func chatImageDataURL(ref string) (string, bool) {
|
||||
name := strings.TrimPrefix(ref, imgRefScheme)
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return "", false
|
||||
}
|
||||
imgCacheMu.Lock()
|
||||
if u, ok := imgCache[name]; ok {
|
||||
imgCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
imgCacheMu.Unlock()
|
||||
raw, err := os.ReadFile(filepath.Join(chatImgDir(), name))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
b, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
mime := "image/jpeg"
|
||||
for m, e := range imgExtByMime {
|
||||
if strings.HasSuffix(name, e) {
|
||||
mime = m
|
||||
}
|
||||
}
|
||||
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
|
||||
imgCacheMu.Lock()
|
||||
if _, ok := imgCache[name]; !ok {
|
||||
imgCache[name] = u
|
||||
imgCacheOrder = append(imgCacheOrder, name)
|
||||
imgCacheBytes += len(u)
|
||||
for imgCacheBytes > imgCacheMax && len(imgCacheOrder) > 1 {
|
||||
old := imgCacheOrder[0]
|
||||
imgCacheOrder = imgCacheOrder[1:]
|
||||
imgCacheBytes -= len(imgCache[old])
|
||||
delete(imgCache, old)
|
||||
}
|
||||
}
|
||||
imgCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
|
||||
// chatImgKeep : âge minimal d'une image avant qu'on puisse l'effacer.
|
||||
const chatImgKeep = 24 * time.Hour
|
||||
|
||||
// pruneChatImages efface les images de plus de chatImgKeep. Appelée au
|
||||
// démarrage et à chaque bascule de discussion, c'est-à-dire quand le fil
|
||||
// vivant vient d'être rechargé — et stripImageParts retire les images d'un fil
|
||||
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
|
||||
// fil resté ouvert longtemps sans bascule.
|
||||
func pruneChatImages() {
|
||||
dir := chatImgDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-chatImgKeep)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// contentParts renvoie les parties d'un contenu multimodal, sous l'une ou l'autre
|
||||
// forme (fraîche []map, ou relue du JSON []any).
|
||||
func contentParts(content any) []map[string]any {
|
||||
switch v := content.(type) {
|
||||
case []map[string]any:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func partImageURL(p map[string]any) (map[string]any, string) {
|
||||
if p["type"] != "image_url" {
|
||||
return nil, ""
|
||||
}
|
||||
iu, _ := p["image_url"].(map[string]any)
|
||||
if iu == nil {
|
||||
return nil, ""
|
||||
}
|
||||
u, _ := iu["url"].(string)
|
||||
return iu, u
|
||||
}
|
||||
|
||||
// refImagesInMessages remplace EN PLACE les images en base64 des messages par des
|
||||
// références (voir plus haut). Renvoie true si quelque chose a changé. Une image
|
||||
// qui ne peut pas être rangée (disque plein…) reste en base64 : rien ne se perd.
|
||||
//
|
||||
// ⚠️ Écart avec l'amont : les parties modifiées sont RECOPIÉES, jamais mutées.
|
||||
// persist peut tourner pendant qu'un runChat (compactage en vol, bascule de
|
||||
// projet) sérialise encore ces mêmes maps : une écriture en place y déclenchait
|
||||
// un « concurrent map read and map write », fatal pour le process.
|
||||
func refImagesInMessages(msgs []Message) bool {
|
||||
changed := false
|
||||
for mi, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
var np []map[string]any
|
||||
for pi, p := range parts {
|
||||
_, u := partImageURL(p)
|
||||
if !strings.HasPrefix(u, "data:image/") {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(u, ";base64,")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(u[i+8:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ref, err := storeChatImage(b, u[5:i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if np == nil {
|
||||
np = append([]map[string]any(nil), parts...)
|
||||
}
|
||||
np[pi] = map[string]any{"type": "image_url", "image_url": map[string]any{"url": ref}}
|
||||
}
|
||||
if np != nil {
|
||||
msgs[mi].Content = np
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hasInlineImages : au moins une image encore en base64 dans ces messages ?
|
||||
func hasInlineImages(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, p := range contentParts(m.Content) {
|
||||
if _, u := partImageURL(p); strings.HasPrefix(u, "data:image/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expandImageRefs renvoie une COPIE des messages prête pour le modèle : chaque
|
||||
// référence d'image redevient sa data-URL. Les messages sans référence sont
|
||||
// partagés tels quels. Une image introuvable (dossier supprimé à la main) devient
|
||||
// une mention textuelle plutôt qu'une requête qui échouerait.
|
||||
func expandImageRefs(msgs []Message) []Message {
|
||||
out := msgs
|
||||
copied := false
|
||||
for i, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
has := false
|
||||
for _, p := range parts {
|
||||
if _, u := partImageURL(p); strings.HasPrefix(u, imgRefScheme) {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if !copied {
|
||||
out = append([]Message(nil), msgs...)
|
||||
copied = true
|
||||
}
|
||||
np := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
_, u := partImageURL(p)
|
||||
if !strings.HasPrefix(u, imgRefScheme) {
|
||||
np = append(np, p)
|
||||
continue
|
||||
}
|
||||
if d, ok := chatImageDataURL(u); ok {
|
||||
np = append(np, map[string]any{"type": "image_url", "image_url": map[string]any{"url": d}})
|
||||
} else {
|
||||
np = append(np, map[string]any{"type": "text", "text": "[image indisponible : fichier supprimé]"})
|
||||
}
|
||||
}
|
||||
m.Content = np
|
||||
out[i] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Une image en base64 part sur disque et revient identique à l'envoi au modèle ;
|
||||
// l'historique ne garde qu'une référence de quelques octets.
|
||||
func TestImageRefsRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
raw := []byte(strings.Repeat("\x89PNG-fake-image-bytes", 5000))
|
||||
url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": url}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
// Relu depuis le JSON (forme []any), comme après un redémarrage.
|
||||
var reread []Message
|
||||
b, _ := json.Marshal(msgs)
|
||||
_ = json.Unmarshal(b, &reread)
|
||||
|
||||
for _, set := range [][]Message{msgs, reread} {
|
||||
if !hasInlineImages(set) || !refImagesInMessages(set) || hasInlineImages(set) {
|
||||
t.Fatal("l'image aurait dû être rangée par référence")
|
||||
}
|
||||
js, _ := json.Marshal(set)
|
||||
if len(js) > 500 || !strings.Contains(string(js), imgRefScheme) {
|
||||
t.Fatalf("historique encore lourd : %d octets", len(js))
|
||||
}
|
||||
out := expandImageRefs(set)
|
||||
_, got := partImageURL(contentParts(out[0].Content)[1])
|
||||
if got != url {
|
||||
t.Fatal("l'image renvoyée au modèle diffère de l'originale")
|
||||
}
|
||||
// L'historique lui-même n'est pas modifié par l'expansion.
|
||||
if _, u := partImageURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, imgRefScheme) {
|
||||
t.Fatal("expandImageRefs ne doit pas toucher l'historique")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mémoire chiffrée : l'image rangée est chiffrée sur disque et revient intacte ;
|
||||
// verrouillée, elle n'est pas écrite (elle reste en base64 dans le message).
|
||||
func TestImageRefsChiffrees(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
|
||||
t.Fatalf("EnableMemEncryption: %v", err)
|
||||
}
|
||||
raw := []byte(strings.Repeat("\xff\xd8JPEG-fake", 3000))
|
||||
ref, err := storeChatImage(raw, "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("storeChatImage: %v", err)
|
||||
}
|
||||
disk, _ := os.ReadFile(filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme)))
|
||||
if !looksEncrypted(disk) {
|
||||
t.Fatal("image en clair sur disque alors que la mémoire est chiffrée")
|
||||
}
|
||||
u, ok := chatImageDataURL(ref)
|
||||
if !ok || u != "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(raw) {
|
||||
t.Fatal("image chiffrée non restituée à l'identique")
|
||||
}
|
||||
clearMemDEK()
|
||||
if _, err := storeChatImage([]byte("autre image"), "image/png"); err == nil {
|
||||
t.Fatal("mémoire verrouillée : l'image n'aurait pas dû être écrite")
|
||||
}
|
||||
}
|
||||
|
||||
// Les images de plus de chatImgKeep sont effacées, les récentes restent.
|
||||
func TestPruneChatImages(t *testing.T) {
|
||||
testHome(t)
|
||||
vieille, _ := storeChatImage([]byte("vieille"), "image/png")
|
||||
recente, _ := storeChatImage([]byte("récente"), "image/png")
|
||||
pv := filepath.Join(chatImgDir(), strings.TrimPrefix(vieille, imgRefScheme))
|
||||
old := time.Now().Add(-chatImgKeep - time.Hour)
|
||||
if err := os.Chtimes(pv, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pruneChatImages()
|
||||
if _, err := os.Stat(pv); !os.IsNotExist(err) {
|
||||
t.Fatal("la vieille image aurait dû être effacée")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(chatImgDir(), strings.TrimPrefix(recente, imgRefScheme))); err != nil {
|
||||
t.Fatal("la récente image a disparu")
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,7 @@ 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. (La mémoire et MCP, eux, renvoient tout
|
||||
// au modèle et laissent l'UI charger l'aperçu.) Sans ce
|
||||
// toolMaxOutput (8000) pour le shell et mcpOutputCap pour MCP. 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.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// chat_mem_pinned.go — RAPPEL des pages mémoire lues au compactage.
|
||||
//
|
||||
// Problème corrigé : quand le contexte est compacté, le torse (dont les résultats
|
||||
// de mem_read) est résumé. Une page mémoire qui portait, par exemple, TOUTES LES
|
||||
// RÈGLES à respecter pour une tâche était donc réduite à quelques mots dans le
|
||||
// résumé — et le modèle, après compactage, « oubliait » les règles et partait en
|
||||
// vrille.
|
||||
//
|
||||
// Choix retenu (le plus léger pour le contexte) : on ne garde RIEN verbatim. On
|
||||
// injecte simplement, en tête de la conversation compactée, un petit rappel qui
|
||||
// LISTE les pages que le modèle a lues (mem_read) et l'invite à en relire une si
|
||||
// elle est pertinente, pour être sûr d'avoir toutes les infos avant de répondre. Le
|
||||
// rappel ne pèse que les NOMS des pages (borné), le contenu complet reste à un
|
||||
// mem_read de distance. Le comportement de compactage est par ailleurs inchangé.
|
||||
//
|
||||
// Le rappel ACCUMULE les noms à travers les compactages successifs : le rappel du
|
||||
// compactage précédent est relu comme source, donc une page lue tôt (dont le
|
||||
// mem_read d'origine a depuis été résumé) reste listée.
|
||||
|
||||
// memReminderPrefix ouvre le message de rappel. Sert aussi à le reconnaître (pour le
|
||||
// relire comme source au compactage suivant, et pour le retirer avant reconstruction).
|
||||
const memReminderPrefix = "[MEMORY PAGES REMINDER]"
|
||||
|
||||
// memReminderMaxNames borne le nombre de noms listés : au-delà, une conversation qui
|
||||
// lit énormément de pages ne ferait pas enfler le rappel. On garde les plus RÉCENTES
|
||||
// (les plus susceptibles de concerner la tâche en cours).
|
||||
const memReminderMaxNames = 24
|
||||
|
||||
// collectReadPageNames rassemble les noms des pages mémoire LUES au fil de la
|
||||
// conversation, dans l'ordre (première lecture d'abord). Deux sources, toutes deux
|
||||
// balayées pour survivre aux compactages successifs :
|
||||
// - les résultats de mem_read encore présents (résultat `tool` relié à sa page via
|
||||
// l'argument `file` du tool_call correspondant), en ignorant les lectures ratées ;
|
||||
// - le rappel injecté à un compactage PRÉCÉDENT (une fois le mem_read d'origine
|
||||
// résumé, c'est la seule trace restante des noms).
|
||||
func collectReadPageNames(msgs []Message) []string {
|
||||
seen := map[string]bool{}
|
||||
var order []string
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
order = append(order, name)
|
||||
}
|
||||
// tool_call_id → nom de page pour les appels mem_read (le résultat `tool` ne
|
||||
// porte que l'id ; le nom vit dans l'argument `file` de l'appel de l'assistant).
|
||||
readCall := map[string]string{}
|
||||
for _, m := range msgs {
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.Function.Name != "mem_read" || tc.ID == "" {
|
||||
continue
|
||||
}
|
||||
var a map[string]any
|
||||
if json.Unmarshal([]byte(tc.Function.Arguments), &a) == nil {
|
||||
if f, _ := a["file"].(string); f != "" {
|
||||
readCall[tc.ID] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "system":
|
||||
for _, n := range parseReminderNames(msgText(m)) {
|
||||
add(n)
|
||||
}
|
||||
case "tool":
|
||||
name, ok := readCall[m.ToolCallID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(msgText(m)), "[erreur]") {
|
||||
continue // lecture ratée : rien à rappeler
|
||||
}
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// parseReminderNames relit la liste de noms d'un message de rappel produit par
|
||||
// buildReminderMessage. Renvoie nil si ce n'est pas un rappel.
|
||||
func parseReminderNames(text string) []string {
|
||||
if !strings.HasPrefix(text, memReminderPrefix) {
|
||||
return nil
|
||||
}
|
||||
// Format : 1re ligne `[MEMORY PAGES REMINDER] … read earlier: a.md, b.md, c.md`
|
||||
// (les noms après le dernier `: `, sans ponctuation finale) ; la consigne de
|
||||
// relecture est sur la ou les lignes SUIVANTES, donc hors de la liste.
|
||||
line := text
|
||||
if i := strings.IndexByte(line, '\n'); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
i := strings.LastIndex(line, ": ")
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
list := line[i+2:]
|
||||
var out []string
|
||||
for _, part := range strings.Split(list, ",") {
|
||||
if n := strings.TrimSpace(part); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildReminderMessage construit le message `system` de rappel. Renvoie ok=false si
|
||||
// aucune page n'a été lue. Borné à memReminderMaxNames (on garde les plus récentes).
|
||||
func buildReminderMessage(names []string) (Message, bool) {
|
||||
if len(names) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
if len(names) > memReminderMaxNames {
|
||||
names = names[len(names)-memReminderMaxNames:] // les plus récentes
|
||||
}
|
||||
content := fmt.Sprintf("%s Memory pages you read earlier: %s\nTheir full content is no longer inline after context compaction — if any of them is relevant to what you're doing, mem_read it again so you have all the rules and info you need before answering.",
|
||||
memReminderPrefix, strings.Join(names, ", "))
|
||||
return Message{Role: "system", Content: content}, true
|
||||
}
|
||||
|
||||
// stripReminder retire le(s) message(s) de rappel d'une séquence (avant reconstruction).
|
||||
func stripReminder(msgs []Message) []Message {
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// remindReadMemPages reconstruit, à partir de l'historique AVANT compactage (source),
|
||||
// le rappel des pages mémoire lues, et l'injecte en tête de la séquence COMPACTÉE. On
|
||||
// retire d'abord un éventuel rappel hérité (porté par compacted depuis la tête
|
||||
// protégée) pour repartir d'un rappel propre et à jour.
|
||||
//
|
||||
// Appelé uniquement en mode agent (les outils mem_* existent) et hors mémoire coupée.
|
||||
// Sans page lue, no-op.
|
||||
func remindReadMemPages(compacted, source []Message) []Message {
|
||||
if memMode() == MemOff {
|
||||
return compacted
|
||||
}
|
||||
names := collectReadPageNames(source)
|
||||
compacted = stripReminder(compacted)
|
||||
if m, ok := buildReminderMessage(names); ok {
|
||||
return append([]Message{m}, compacted...)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Une page mémoire lue (mem_read) doit être retrouvée par son nom (via l'argument
|
||||
// `file` du tool_call), puis listée dans un rappel relisable.
|
||||
func TestReminderRoundTrip(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: "applique les règles de la page"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{
|
||||
ID: "call_1", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"regles-tache.md"}`},
|
||||
}}},
|
||||
{Role: "tool", ToolCallID: "call_1", Content: "RÈGLE 1 : toujours vouvoyer."},
|
||||
}
|
||||
names := collectReadPageNames(msgs)
|
||||
if len(names) != 1 || names[0] != "regles-tache.md" {
|
||||
t.Fatalf("nom de page mal collecté : %v", names)
|
||||
}
|
||||
m, ok := buildReminderMessage(names)
|
||||
if !ok {
|
||||
t.Fatalf("un rappel aurait dû être construit")
|
||||
}
|
||||
txt, _ := m.Content.(string)
|
||||
if !strings.Contains(txt, "regles-tache.md") || !strings.Contains(txt, "mem_read") {
|
||||
t.Fatalf("le rappel ne cite pas la page ou n'invite pas à relire : %q", txt)
|
||||
}
|
||||
// Le rappel doit se relire (noms) pour survivre au compactage suivant.
|
||||
back := parseReminderNames(txt)
|
||||
if len(back) != 1 || back[0] != "regles-tache.md" {
|
||||
t.Fatalf("aller-retour du rappel cassé : %v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// Le rappel doit ACCUMULER : un nom listé à un compactage précédent (dont le mem_read
|
||||
// d'origine a été résumé/effacé) est retrouvé via le rappel système, même sans le
|
||||
// résultat d'outil correspondant.
|
||||
func TestReminderAccumulatesAcrossCompactions(t *testing.T) {
|
||||
old, _ := buildReminderMessage([]string{"regles.md"})
|
||||
msgs := []Message{
|
||||
old, // rappel hérité (system)
|
||||
{Role: "user", Content: "[CONTEXT COMPACTED] résumé…"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{
|
||||
ID: "c2", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"procedure.md"}`},
|
||||
}}},
|
||||
{Role: "tool", ToolCallID: "c2", Content: "procédure détaillée"},
|
||||
}
|
||||
names := collectReadPageNames(msgs)
|
||||
joined := strings.Join(names, ",")
|
||||
if !strings.Contains(joined, "regles.md") {
|
||||
t.Fatalf("la page héritée a été perdue : %v", names)
|
||||
}
|
||||
if !strings.Contains(joined, "procedure.md") {
|
||||
t.Fatalf("la nouvelle page lue n'a pas été captée : %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une même page lue plusieurs fois n'est listée qu'une fois.
|
||||
func TestReminderDedupes(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "a", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "a", Content: "v1"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "b", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "b", Content: "v2"},
|
||||
}
|
||||
if names := collectReadPageNames(msgs); len(names) != 1 || names[0] != "p.md" {
|
||||
t.Fatalf("attendu une seule occurrence de p.md, obtenu %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une lecture ratée ([erreur] …) ne doit pas être rappelée.
|
||||
func TestReminderSkipsErrors(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "e", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"absente.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "e", Content: "[erreur] page introuvable"},
|
||||
}
|
||||
if names := collectReadPageNames(msgs); len(names) != 0 {
|
||||
t.Fatalf("une lecture ratée ne doit pas être rappelée, obtenu %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// La liste de noms est bornée : au-delà du plafond, on garde les plus récentes.
|
||||
func TestReminderCapsNames(t *testing.T) {
|
||||
var names []string
|
||||
for i := 0; i < memReminderMaxNames+10; i++ {
|
||||
names = append(names, "page-"+string(rune('a'+i%26))+string(rune('0'+i/26))+".md")
|
||||
}
|
||||
m, ok := buildReminderMessage(names)
|
||||
if !ok {
|
||||
t.Fatalf("rappel attendu")
|
||||
}
|
||||
got := parseReminderNames(m.Content.(string))
|
||||
if len(got) != memReminderMaxNames {
|
||||
t.Fatalf("attendu %d noms max, obtenu %d", memReminderMaxNames, len(got))
|
||||
}
|
||||
// La toute dernière (plus récente) doit être conservée.
|
||||
if got[len(got)-1] != names[len(names)-1] {
|
||||
t.Fatalf("la page la plus récente doit être gardée : %q vs %q", got[len(got)-1], names[len(names)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// stripReminder retire le rappel sans toucher au reste.
|
||||
func TestStripReminder(t *testing.T) {
|
||||
m, _ := buildReminderMessage([]string{"p.md"})
|
||||
msgs := []Message{
|
||||
m,
|
||||
{Role: "system", Content: "Project context — autre chose"},
|
||||
{Role: "user", Content: "salut"},
|
||||
}
|
||||
out := stripReminder(msgs)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("attendu 2 messages après strip, obtenu %d", len(out))
|
||||
}
|
||||
for _, mm := range out {
|
||||
if s, _ := mm.Content.(string); strings.HasPrefix(s, memReminderPrefix) {
|
||||
t.Fatalf("le rappel a survécu au strip")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,47 +377,69 @@ func MemDelete(name string) error {
|
||||
}
|
||||
|
||||
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
|
||||
// mode agent (shell). Trois modes :
|
||||
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
|
||||
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
|
||||
// mode agent (shell). Réglé PAR PROJET (voir projectMemMode). Quatre modes :
|
||||
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
|
||||
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
|
||||
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
|
||||
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
|
||||
// - MemAlways : proactif, INDEX INJECTÉ — l'index MEMORY.md (titres +
|
||||
// accroches) est en tête de conversation, l'IA lit directement la bonne page.
|
||||
// - MemSearchFirst : proactif, RECHERCHE D'ABORD — rien n'est injecté, l'IA
|
||||
// appelle mem_search avant toute tâche. Prompt léger, démarrage plus rapide.
|
||||
type MemMode string
|
||||
|
||||
const (
|
||||
MemOff MemMode = "off"
|
||||
MemOnDemand MemMode = "ondemand"
|
||||
MemAlways MemMode = "always"
|
||||
MemOff MemMode = "off"
|
||||
MemOnDemand MemMode = "ondemand"
|
||||
MemAlways MemMode = "always"
|
||||
MemSearchFirst MemMode = "search"
|
||||
)
|
||||
|
||||
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
|
||||
// actuel). Toute valeur inconnue retombe sur always.
|
||||
func memMode() MemMode {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
|
||||
// memProactive : l'IA gère sa mémoire d'elle-même (cherche/sauve sans qu'on le
|
||||
// demande). Vrai pour les deux modes auto (injecté ou recherche).
|
||||
func memProactive(m MemMode) bool { return m == MemAlways || m == MemSearchFirst }
|
||||
|
||||
// normalizeMemMode ramène une valeur de config à un mode connu. Défaut = always
|
||||
// (préserve le comportement historique). "auto" est un alias d'always.
|
||||
func normalizeMemMode(v string) MemMode {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "off":
|
||||
return MemOff
|
||||
case "ondemand":
|
||||
return MemOnDemand
|
||||
case "search":
|
||||
return MemSearchFirst
|
||||
default: // "always", "auto", "" et inconnus
|
||||
return MemAlways
|
||||
}
|
||||
}
|
||||
|
||||
// setMemMode persiste le mode mémoire dans config.env.
|
||||
func setMemMode(m MemMode) error {
|
||||
return SetConfigKey("MEM_MODE", string(m))
|
||||
// memMode renvoie le mode mémoire du PROJET ACTIF (AJEAN 0.13.12). Repli : un
|
||||
// projet sans réglage propre (tous ceux d'avant) retombe sur l'ancien réglage
|
||||
// global MEM_MODE de config.env — always par défaut. Rien ne change donc pour
|
||||
// les projets existants tant qu'on ne touche pas à leur mode.
|
||||
func memMode() MemMode {
|
||||
if pm := projectMemMode(activeProjectSlug()); pm != "" {
|
||||
return normalizeMemMode(pm)
|
||||
}
|
||||
return normalizeMemMode(ReadConfig()["MEM_MODE"])
|
||||
}
|
||||
|
||||
// cmdMemory : loki memory [off|ondemand|always|status]
|
||||
// setMemMode persiste le mode mémoire du PROJET ACTIF.
|
||||
func setMemMode(m MemMode) error {
|
||||
return setProjectMemMode(activeProjectSlug(), string(m))
|
||||
}
|
||||
|
||||
// cmdMemory : loki memory [off|ondemand|always|search|status]
|
||||
func cmdMemory(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
}
|
||||
label := map[MemMode]string{
|
||||
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
|
||||
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
|
||||
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
|
||||
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
|
||||
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
|
||||
MemAlways: "auto/injectée (index en tête de conversation, l'IA lit directement la bonne page)",
|
||||
MemSearchFirst: "auto/recherche (rien d'injecté, l'IA cherche avant toute tâche — prompt léger)",
|
||||
}
|
||||
switch sub {
|
||||
case "off":
|
||||
@@ -428,20 +450,24 @@ func cmdMemory(args []string) error {
|
||||
if err := setMemMode(MemOnDemand); err != nil {
|
||||
return err
|
||||
}
|
||||
case "always":
|
||||
case "always", "auto", "inject", "injected":
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
return err
|
||||
}
|
||||
case "search", "recherche", "lazy":
|
||||
if err := setMemMode(MemSearchFirst); err != nil {
|
||||
return err
|
||||
}
|
||||
case "", "status":
|
||||
m := memMode()
|
||||
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
|
||||
fmt.Printf("%s projet %s, mode: %s — %s\n", cyan("Mémoire"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
|
||||
pages := MemList()
|
||||
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("usage: loki memory [off|ondemand|always|status]")
|
||||
return fmt.Errorf("usage: loki memory [off|ondemand|always|search|status]")
|
||||
}
|
||||
m := memMode()
|
||||
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
|
||||
fmt.Printf("%s mémoire du projet %s : %s — %s\n", green("[ok]"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Pendant une génération de chat, un envoi est mis en file ; il ressort à la
|
||||
// frontière d'étape suivante, journalisé comme une bulle utilisateur.
|
||||
func TestQueueDuringGeneration(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
queued, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7)
|
||||
if err != nil || !queued {
|
||||
t.Fatalf("mise en file attendue, got queued=%v err=%v", queued, err)
|
||||
}
|
||||
// Réessai du même envoi (réponse perdue) : pas de doublon.
|
||||
if _, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7); err != ErrDupSend {
|
||||
t.Fatalf("doublon attendu, got %v", err)
|
||||
}
|
||||
msgs := c.drainQueued(c.epoch)
|
||||
if len(msgs) != 1 || msgs[0].Role != "user" {
|
||||
t.Fatalf("un message user attendu, got %#v", msgs)
|
||||
}
|
||||
if len(c.Log) != 1 || c.Log[0].Delta["user"] != "précision" {
|
||||
t.Fatalf("le message injecté doit apparaître dans le fil, journal : %#v", c.Log)
|
||||
}
|
||||
if again := c.drainQueued(c.epoch); len(again) != 0 {
|
||||
t.Fatal("la file doit être vidée par drainQueued")
|
||||
}
|
||||
}
|
||||
|
||||
// Une tâche planifiée qui occupe le modèle garde le refus : sa fin ne dépile rien.
|
||||
func TestQueueRefusedDuringTask(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
c.runningTaskName = "veille"
|
||||
if queued, _ := c.EnqueueOrStart("", "x", nil, Caps{}, 0.7); queued {
|
||||
t.Fatal("pas de file pendant une tâche planifiée")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop : la file est abandonnée et le client en est prévenu.
|
||||
func TestDropQueuedAnnounces(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
_, _ = c.EnqueueOrStart("", "a", nil, Caps{}, 0.7)
|
||||
c.dropQueued(c.epoch)
|
||||
if len(c.queued) != 0 || len(c.Log) != 1 || c.Log[0].Delta["queue_dropped"] != 1 {
|
||||
t.Fatalf("abandon non signalé : file=%d journal=%#v", len(c.queued), c.Log)
|
||||
}
|
||||
}
|
||||
|
||||
// Un client dont la discussion affichée n'est plus l'active reçoit d'abord un
|
||||
// reset, pour ne pas greffer le nouveau fil sur l'ancien.
|
||||
func TestSubscribeStaleConvResets(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = putStr(bkChat, ckActive, "c-nouvelle")
|
||||
c := newTestConv()
|
||||
for i := 0; i < 3; i++ {
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "q"})
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var first map[string]any
|
||||
c.SubscribeTail(ctx, 2, -1, "c-ancienne", func(m map[string]any) bool {
|
||||
if _, pad := m["pad"]; pad {
|
||||
return true
|
||||
}
|
||||
if first == nil {
|
||||
first = m
|
||||
}
|
||||
if _, ok := m["caught_up"]; ok {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if first["reset"] != true || first["id"] != "c-nouvelle" {
|
||||
t.Fatalf("reset attendu en premier, got %v", first)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,11 @@ var (
|
||||
)
|
||||
|
||||
func engineSeesImages() bool {
|
||||
// Preset externe : aucun /props à sonder, on s'en tient à la déclaration
|
||||
// du preset (case « le modèle accepte les images »).
|
||||
if externalActive() {
|
||||
return externalVisionActive()
|
||||
}
|
||||
visionProbeMu.Lock()
|
||||
defer visionProbeMu.Unlock()
|
||||
if time.Since(visionProbeAt) < 10*time.Second {
|
||||
|
||||
@@ -318,6 +318,7 @@ func convDelete(id string) error {
|
||||
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
|
||||
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
|
||||
dropConvFiles(id)
|
||||
deleteToolResultsFor(id) // ses résultats « voir plus » partent avec elle
|
||||
if id != getStr(bkChat, ckActive) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package loki
|
||||
|
||||
// chat_shell_hygiene.go — ce que le terminal rend au modèle, nettoyé (repris de
|
||||
// shell.ts / shell-tail.ts d'OpenFox, 2.0.160) :
|
||||
//
|
||||
// - « cmd | tail -N » : le code de sortie devenait celui de tail (0), et un
|
||||
// build en échec passait pour réussi. On retire le tail de la commande et
|
||||
// on garde nous-mêmes les N dernières lignes : même sortie, vrai code.
|
||||
// - séquences ANSI (couleurs, curseur) : du bruit en tokens, illisible ;
|
||||
// - « cmd & » en mode Code : le process orphelin n'a ni sortie ni arrêt —
|
||||
// bash_bg est fait pour ça.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// tailPipeRe : « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en
|
||||
// toute fin de commande.
|
||||
var tailPipeRe = regexp.MustCompile(`\s*\|\s*tail(?:\s+-n\s*(\d+)|\s+-(\d+))?\s*$`)
|
||||
|
||||
// splitTailPipe retire un « | tail -N » final. Renvoie la commande sans lui et
|
||||
// N (0 = pas de tail ; 10 = tail sans nombre, sa valeur par défaut).
|
||||
func splitTailPipe(command string) (string, int) {
|
||||
m := tailPipeRe.FindStringSubmatchIndex(command)
|
||||
if m == nil {
|
||||
return command, 0
|
||||
}
|
||||
n := 10
|
||||
for _, g := range []int{2, 4} {
|
||||
if m[g] >= 0 {
|
||||
if v, err := strconv.Atoi(command[m[g]:m[g+1]]); err == nil && v > 0 {
|
||||
n = v
|
||||
}
|
||||
}
|
||||
}
|
||||
rest := strings.TrimSpace(command[:m[0]])
|
||||
if rest == "" {
|
||||
return command, 0
|
||||
}
|
||||
return rest, n
|
||||
}
|
||||
|
||||
// shellANSIRe : séquences CSI (couleurs, curseur) et OSC (titres de fenêtre).
|
||||
var shellANSIRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`)
|
||||
|
||||
func stripANSI(s string) string {
|
||||
if !strings.Contains(s, "\x1b") {
|
||||
return s
|
||||
}
|
||||
return shellANSIRe.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// trailingBackground : la commande se termine par un « & » de mise en
|
||||
// arrière-plan (pas « && »).
|
||||
func trailingBackground(command string) bool {
|
||||
c := strings.TrimSpace(command)
|
||||
return strings.HasSuffix(c, "&") && !strings.HasSuffix(c, "&&")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitTailPipe(t *testing.T) {
|
||||
cas := map[string]struct {
|
||||
cmd string
|
||||
n int
|
||||
}{
|
||||
"go build ./... 2>&1 | tail -20": {"go build ./... 2>&1", 20},
|
||||
"make | tail -n 5": {"make", 5},
|
||||
"make|tail -n40": {"make", 40},
|
||||
"go test | tail": {"go test", 10},
|
||||
"tail -f log.txt": {"tail -f log.txt", 0},
|
||||
"cat a | tail -5 | grep x": {"cat a | tail -5 | grep x", 0},
|
||||
"echo ok": {"echo ok", 0},
|
||||
}
|
||||
for in, want := range cas {
|
||||
if cmd, n := splitTailPipe(in); cmd != want.cmd || n != want.n {
|
||||
t.Errorf("%q → (%q, %d), attendu (%q, %d)", in, cmd, n, want.cmd, want.n)
|
||||
}
|
||||
}
|
||||
if !trailingBackground("./serveur &") || trailingBackground("make && make test") || trailingBackground("ls 2>&1") {
|
||||
t.Error("trailingBackground se trompe")
|
||||
}
|
||||
if got := stripANSI("\x1b[1;31mFAIL\x1b[0m ok"); got != "FAIL ok" {
|
||||
t.Errorf("stripANSI = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// « cmd | tail -N » rend le VRAI code de sortie, et seulement N lignes.
|
||||
func TestShellTailGardeLeCodeDeSortie(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell POSIX")
|
||||
}
|
||||
withWorkspace(t)
|
||||
out := runShell(context.Background(), "sh -c 'printf \"l1\nl2\nl3\n\"; exit 2' | tail -n 2", 10)
|
||||
if !strings.HasPrefix(out, "exit: 2") || strings.Contains(out, "l1") || !strings.Contains(out, "l3") {
|
||||
t.Fatalf("sortie = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Au délai dépassé, la sortie déjà produite est rendue.
|
||||
func TestShellTimeoutGardeLaSortie(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell POSIX")
|
||||
}
|
||||
withWorkspace(t)
|
||||
out := runShell(context.Background(), "echo avant; sleep 5", 1)
|
||||
if !strings.Contains(out, "timeout") || !strings.Contains(out, "avant") {
|
||||
t.Fatalf("sortie = %q", out)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func baseSystemPrompt(caps Caps) string {
|
||||
b.WriteString("\n\n" + rp)
|
||||
}
|
||||
}
|
||||
if caps.Mem == MemAlways {
|
||||
if memProactive(caps.Mem) {
|
||||
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
|
||||
}
|
||||
// PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête,
|
||||
@@ -76,7 +76,15 @@ func baseSystemPrompt(caps Caps) string {
|
||||
case MemAlways:
|
||||
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
||||
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
|
||||
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes.\n")
|
||||
// Mode INJECTÉ : l'index est en tête de contexte (mem_index.go), la
|
||||
// recherche devient facultative — l'IA lit directement la bonne page.
|
||||
b.WriteString("- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n")
|
||||
case MemSearchFirst:
|
||||
// Mode RECHERCHE : rien d'injecté, prompt léger — mais l'IA DOIT chercher
|
||||
// elle-même, sinon elle ignore ce que sa mémoire contient.
|
||||
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
||||
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
|
||||
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes. Nothing is preloaded: a search is the only way to know what you already know.\n")
|
||||
case MemOnDemand:
|
||||
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
|
||||
}
|
||||
@@ -96,7 +104,7 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// 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 {
|
||||
if caps.Mem == MemSearchFirst {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -216,6 +224,10 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
if timeoutSec > toolMaxTimeout {
|
||||
timeoutSec = toolMaxTimeout
|
||||
}
|
||||
// « … | tail -N » final : retiré, on garde nous-mêmes les N dernières lignes
|
||||
// (chat_shell_hygiene.go) — sinon le code de sortie est celui de tail.
|
||||
command, keepLines := splitTailPipe(command)
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
cmd := newShellCmd(ctx, command)
|
||||
@@ -246,9 +258,27 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// issue connue était de redémarrer loki-ui.
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
err := cmd.Run()
|
||||
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
|
||||
// demandait un tail.
|
||||
clean := func(s string) string { return tailOutput(stripANSI(s)) }
|
||||
out := stdout.String()
|
||||
if keepLines > 0 {
|
||||
out = tailLines(out, keepLines)
|
||||
}
|
||||
out = clean(out)
|
||||
errOut := clean(stderr.String())
|
||||
switch {
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
|
||||
// La sortie déjà produite reste utile (où le build en était, quel test
|
||||
// bloquait) : on la rend au lieu d'un « timeout » sec (OpenFox).
|
||||
msg := fmt.Sprintf("[timeout après %ds — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]", timeoutSec)
|
||||
if out != "" {
|
||||
msg += "\n\nstdout (partiel):\n" + out
|
||||
}
|
||||
if errOut != "" {
|
||||
msg += "\n\nstderr (partiel):\n" + errOut
|
||||
}
|
||||
return msg
|
||||
case errors.Is(parent.Err(), context.Canceled):
|
||||
return "[commande interrompue]"
|
||||
}
|
||||
@@ -260,9 +290,8 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
return fmt.Sprintf("[erreur: %v]", err)
|
||||
}
|
||||
}
|
||||
out := tailRunes(stdout.String(), toolMaxOutput)
|
||||
errOut := tailRunes(stderr.String(), toolMaxOutput)
|
||||
parts := []string{fmt.Sprintf("exit: %d", exit)}
|
||||
// « exit: N » en tête : tasks_script.go s'y fie. La durée suit.
|
||||
parts := []string{fmt.Sprintf("exit: %d · %s", exit, time.Since(start).Round(100*time.Millisecond))}
|
||||
if out != "" {
|
||||
parts = append(parts, "stdout:\n"+out)
|
||||
}
|
||||
@@ -383,6 +412,18 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
|
||||
}
|
||||
|
||||
// tailOutput garde la FIN d'une sortie de commande (toolMaxOutput runes) et dit
|
||||
// explicitement ce qui a été coupé (AJEAN 0.15.5). La coupe était silencieuse :
|
||||
// le modèle croyait voir toute la sortie, et la bulle affichait toujours
|
||||
// ~2004 tok sans qu'on sache pourquoi.
|
||||
func tailOutput(s string) string {
|
||||
n := len([]rune(s))
|
||||
if n <= toolMaxOutput {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("[sortie tronquée : %d caractères au total, seuls les %d derniers sont gardés]\n", n, toolMaxOutput) + tailRunes(s, toolMaxOutput)
|
||||
}
|
||||
|
||||
// tailRunes returns the last n runes of s (used to cap tool output).
|
||||
func tailRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package loki
|
||||
|
||||
// code_alias.go — réparation des noms d'outils et d'arguments hallucinés.
|
||||
//
|
||||
// Les petits modèles ont appris d'autres agents : ils appellent read_file,
|
||||
// str_replace, run_command, ou passent path / old_string au lieu de file /
|
||||
// old. L'appel tombait sur « outil inconnu » ou sur un argument manquant, et
|
||||
// le tour se perdait en allers-retours. On traduit vers l'outil RÉEL quand la
|
||||
// cible existe dans ce tour (généralisation du transformSubAgentAliases
|
||||
// d'OpenFox : un outil nommé d'après un sous-agent devient subagent{role}).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// toolNameAliases : nom halluciné → outil de Loki.
|
||||
var toolNameAliases = map[string]string{
|
||||
"read_file": "read", "view": "read", "cat": "read", "open_file": "read",
|
||||
"write_file": "write", "create_file": "write",
|
||||
"edit_file": "edit", "str_replace": "edit", "replace_in_file": "edit", "str_replace_editor": "edit",
|
||||
"run_command": "bash", "shell": "bash", "run_shell": "bash", "execute_command": "bash", "terminal": "bash",
|
||||
"search": "grep", "grep_search": "grep", "search_files": "grep",
|
||||
"find_files": "glob", "list_files": "glob", "file_search": "glob",
|
||||
}
|
||||
|
||||
// toolArgAliases : par outil, argument halluciné → argument attendu.
|
||||
var toolArgAliases = map[string]map[string]string{
|
||||
"read": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file"},
|
||||
"write": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file", "contents": "content", "text": "content"},
|
||||
"edit": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file",
|
||||
"old_string": "old", "old_str": "old", "old_text": "old", "search": "old",
|
||||
"new_string": "new", "new_str": "new", "new_text": "new", "replace": "new"},
|
||||
"bash": {"cmd": "command"},
|
||||
"bash_bg": {"cmd": "command"},
|
||||
"grep": {"query": "pattern", "regex": "pattern"},
|
||||
"glob": {"query": "pattern"},
|
||||
}
|
||||
|
||||
// repairToolCall corrige en place le nom et les arguments d'un appel quand ils
|
||||
// visent, sous un autre nom, un outil disponible dans ce tour. Renvoie true si
|
||||
// l'appel a été modifié. Les arguments doivent déjà être du JSON valide.
|
||||
func repairToolCall(tc *ToolCall, tools []Tool) bool {
|
||||
have := make(map[string]bool, len(tools))
|
||||
for _, t := range tools {
|
||||
have[t.Function.Name] = true
|
||||
}
|
||||
name := tc.Function.Name
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil || args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
changed := false
|
||||
if !have[name] {
|
||||
lower := strings.ToLower(strings.TrimSpace(name))
|
||||
if t := toolNameAliases[lower]; t != "" && have[t] {
|
||||
name, changed = t, true
|
||||
} else if have["subagent"] {
|
||||
// Un sous-agent appelé comme un outil (« explorer », « code_reviewer »).
|
||||
role := strings.ReplaceAll(lower, "_", "-")
|
||||
for _, r := range subagentRoles {
|
||||
if role == r {
|
||||
if _, ok := args["role"]; !ok {
|
||||
args["role"] = r
|
||||
}
|
||||
if _, ok := args["task"]; !ok {
|
||||
for _, k := range []string{"prompt", "query", "question", "description"} {
|
||||
if v, ok := args[k].(string); ok && v != "" {
|
||||
args["task"] = v
|
||||
delete(args, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
name, changed = "subagent", true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for from, to := range toolArgAliases[name] {
|
||||
v, ok := args[from]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := args[to]; !exists {
|
||||
args[to] = v
|
||||
changed = true
|
||||
}
|
||||
delete(args, from)
|
||||
}
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
b, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
tc.Function.Name = name
|
||||
tc.Function.Arguments = string(b)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func outils(noms ...string) []Tool {
|
||||
var ts []Tool
|
||||
for _, n := range noms {
|
||||
ts = append(ts, Tool{Type: "function", Function: ToolFunction{Name: n}})
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// Noms et arguments appris d'autres agents : traduits vers les outils de Loki.
|
||||
func TestReparationDesAppelsHallucines(t *testing.T) {
|
||||
tools := outils("read", "edit", "bash", "subagent")
|
||||
cas := []struct {
|
||||
name, args, wantName string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{"read_file", `{"path":"a.go"}`, "read", map[string]any{"file": "a.go"}},
|
||||
{"str_replace", `{"file_path":"a.go","old_string":"x","new_string":"y"}`, "edit", map[string]any{"file": "a.go", "old": "x", "new": "y"}},
|
||||
{"run_command", `{"cmd":"ls"}`, "bash", map[string]any{"command": "ls"}},
|
||||
{"edit", `{"file":"a.go","old_str":"x","new":"y"}`, "edit", map[string]any{"file": "a.go", "old": "x", "new": "y"}},
|
||||
{"code_reviewer", `{"prompt":"relis"}`, "subagent", map[string]any{"role": "code-reviewer", "task": "relis"}},
|
||||
}
|
||||
for _, c := range cas {
|
||||
tc := ToolCall{Function: ToolCallFunc{Name: c.name, Arguments: c.args}}
|
||||
if !repairToolCall(&tc, tools) {
|
||||
t.Errorf("%s : non réparé", c.name)
|
||||
continue
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &got)
|
||||
if tc.Function.Name != c.wantName || len(got) != len(c.wantArgs) {
|
||||
t.Errorf("%s → %s %v", c.name, tc.Function.Name, got)
|
||||
continue
|
||||
}
|
||||
for k, v := range c.wantArgs {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s : %s=%v, attendu %v", c.name, k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Outil cible absent de ce tour, ou appel déjà correct : intact.
|
||||
for _, tc := range []ToolCall{
|
||||
{Function: ToolCallFunc{Name: "write_file", Arguments: `{"path":"a"}`}},
|
||||
{Function: ToolCallFunc{Name: "read", Arguments: `{"file":"a"}`}},
|
||||
} {
|
||||
if repairToolCall(&tc, tools) {
|
||||
t.Errorf("modifié à tort : %+v", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ package loki
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
type Criterion struct {
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"` // pending | passed | failed
|
||||
Status string `json:"status"` // pending | completed (le builder dit « fait ») | passed | failed
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ func critKey(convID string) string { return "crit:" + convID }
|
||||
|
||||
func critList(convID string) []Criterion {
|
||||
var list []Criterion
|
||||
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
|
||||
if b, ok := getStoreBytes(bkChat, critKey(convID)); ok && len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &list)
|
||||
}
|
||||
return list
|
||||
@@ -45,7 +46,7 @@ func critSave(convID string, list []Criterion) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, critKey(convID), b)
|
||||
_ = putStoreBytes(bkChat, critKey(convID), b)
|
||||
}
|
||||
|
||||
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
|
||||
@@ -74,6 +75,18 @@ func critPending(list []Criterion) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// critOpen : les critères que le builder n'a pas encore déclarés faits —
|
||||
// pending, ou failed et pas encore repris (la reprise les remet à completed).
|
||||
func critOpen(list []Criterion) []Criterion {
|
||||
var out []Criterion
|
||||
for _, c := range list {
|
||||
if c.Status == "pending" || c.Status == "failed" || c.Status == "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// critRender : la liste formatée pour un prompt (builder ou verifier).
|
||||
func critRender(list []Criterion) string {
|
||||
var b strings.Builder
|
||||
@@ -84,6 +97,8 @@ func critRender(list []Criterion) string {
|
||||
mark = "[✓]"
|
||||
case "failed":
|
||||
mark = "[✗]"
|
||||
case "completed":
|
||||
mark = "[~]" // fait selon le builder, pas encore vérifié
|
||||
}
|
||||
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
|
||||
if c.Note != "" {
|
||||
@@ -100,15 +115,15 @@ func criteriaTool() Tool {
|
||||
Function: ToolFunction{
|
||||
Name: "criteria",
|
||||
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
|
||||
"action=add posits new criteria (texts[]), action=set updates one (id + status passed|failed|pending, optional note), " +
|
||||
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
|
||||
"action=add posits new criteria (texts[]), action=set updates one (id + status, optional note), " +
|
||||
"action=list shows them, action=clear removes them all. Set criteria BEFORE building. Builder: mark each criterion completed once you have done AND checked it — verification only starts when nothing is left pending. Only the verification pass may mark passed or failed.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
|
||||
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
|
||||
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
|
||||
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
|
||||
"status": map[string]any{"type": "string", "enum": []string{"pending", "completed", "passed", "failed"}},
|
||||
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
@@ -158,8 +173,15 @@ func toolCriteria(args map[string]any, allowPass bool) string {
|
||||
critNotify(list)
|
||||
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
|
||||
case "set":
|
||||
idf, _ := args["id"].(float64)
|
||||
id := int(idf)
|
||||
// id numérique ou chaîne (« "2" », « "#2" ») : les petits modèles
|
||||
// écrivent volontiers l'id entre guillemets, qui tombait sinon à #0.
|
||||
id := 0
|
||||
switch v := args["id"].(type) {
|
||||
case float64:
|
||||
id = int(v)
|
||||
case string:
|
||||
id, _ = strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(v), "#"))
|
||||
}
|
||||
status, _ := args["status"].(string)
|
||||
note, _ := args["note"].(string)
|
||||
if status == "passed" && !allowPass {
|
||||
|
||||
@@ -25,8 +25,10 @@ func gitStatusTool() Tool {
|
||||
Name: "git_status",
|
||||
Description: "git status of the working folder (branch + changed files, porcelain format).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -42,6 +44,7 @@ func gitDiffTool() Tool {
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
|
||||
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -66,15 +69,56 @@ func gitCloneTool() Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// gitRepoDir : où lancer git_status / git_diff. La discussion n'est souvent PAS
|
||||
// un dépôt : git_clone range le dépôt dans un sous-dossier, et git, lancé à la
|
||||
// racine, échouait (« not a git repository ») — alors que le vérificateur a
|
||||
// pour consigne de commencer par git_diff. Ordre : le sous-dossier demandé
|
||||
// (dir), la racine si c'est un dépôt, sinon l'unique sous-dossier qui en est
|
||||
// un. Plusieurs candidats : on demande de choisir.
|
||||
func gitRepoDir(dir string) (string, error) {
|
||||
base := agentCwd()
|
||||
if dir = strings.TrimSpace(dir); dir != "" {
|
||||
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
|
||||
return "", fmt.Errorf("dossier invalide : %s", dir)
|
||||
}
|
||||
return filepath.Join(base, dir), nil
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, ".git")); err == nil {
|
||||
return base, nil
|
||||
}
|
||||
entries, _ := os.ReadDir(base)
|
||||
var repos []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, e.Name(), ".git")); err == nil {
|
||||
repos = append(repos, e.Name())
|
||||
}
|
||||
}
|
||||
switch len(repos) {
|
||||
case 0:
|
||||
return base, nil // git dira lui-même « not a git repository »
|
||||
case 1:
|
||||
return filepath.Join(base, repos[0]), nil
|
||||
}
|
||||
return "", fmt.Errorf("plusieurs dépôts ici (%s) : précise dir", strings.Join(repos, ", "))
|
||||
}
|
||||
|
||||
// runGit exécute git dans le dossier de la discussion, sortie bornée.
|
||||
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
|
||||
return runGitIn(ctx, agentCwd(), timeout, args...)
|
||||
}
|
||||
|
||||
// runGitIn exécute git dans dir, sortie bornée.
|
||||
func runGitIn(ctx context.Context, dir string, timeout time.Duration, args ...string) string {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return "[erreur] git n'est pas installé sur cette machine"
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "git", args...)
|
||||
cmd.Dir = agentCwd()
|
||||
cmd.Dir = dir
|
||||
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
|
||||
// serveur n'a personne pour y répondre, il faut échouer vite et le dire.
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
|
||||
@@ -95,24 +139,32 @@ func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func toolGitStatus(ctx context.Context) string {
|
||||
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
|
||||
func toolGitStatus(ctx context.Context, args map[string]any) string {
|
||||
dir, err := gitRepoDir(str(args["dir"]))
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return runGitIn(ctx, dir, 20*time.Second, "status", "--porcelain=v1", "--branch")
|
||||
}
|
||||
|
||||
func toolGitDiff(ctx context.Context, args map[string]any) string {
|
||||
dir, err := gitRepoDir(str(args["dir"]))
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
// Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail.
|
||||
// HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple.
|
||||
gitArgs := []string{"diff", "HEAD"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
gitArgs = append(gitArgs, "--", file)
|
||||
}
|
||||
out := runGit(ctx, 30*time.Second, gitArgs...)
|
||||
out := runGitIn(ctx, dir, 30*time.Second, gitArgs...)
|
||||
if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") {
|
||||
fallback := []string{"diff"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
fallback = append(fallback, "--", file)
|
||||
}
|
||||
out = runGit(ctx, 30*time.Second, fallback...)
|
||||
out = runGitIn(ctx, dir, 30*time.Second, fallback...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package loki
|
||||
|
||||
// code_instructions.go — consignes du dépôt (AGENTS.md, CLAUDE.md) en mode Code.
|
||||
// Repris de context/instructions.ts d'OpenFox.
|
||||
//
|
||||
// Un dépôt qui documente ses commandes de build et de test, ses conventions,
|
||||
// ses pièges, le fait dans ces fichiers. Sans eux, le modèle les redécouvrait
|
||||
// à coups de read et de bash — quand il les redécouvrait. Le contenu part avec
|
||||
// le contexte du projet : déplacé dans le premier message utilisateur par
|
||||
// normalizeSystemMessages, il ne touche pas au préfixe système (cache).
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const codeInstructionsPrefix = "Repository instructions"
|
||||
|
||||
// codeInstructionsMax : au-delà, on tronque (≈ 1k tokens) — ces fichiers
|
||||
// peuvent être longs, et le contexte est compté.
|
||||
const codeInstructionsMax = 4000
|
||||
|
||||
// codeInstructionFiles : par ordre de préférence ; le premier trouvé gagne.
|
||||
var codeInstructionFiles = []string{"AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"}
|
||||
|
||||
// codeInstructionsMessage renvoie les consignes du dépôt de la discussion
|
||||
// (racine, ou dépôt cloné détecté par gitRepoDir), ok=false s'il n'y en a pas.
|
||||
func codeInstructionsMessage(caps Caps) (Message, bool) {
|
||||
if !caps.Code {
|
||||
return Message{}, false
|
||||
}
|
||||
dirs := []string{agentCwd()}
|
||||
if d, err := gitRepoDir(""); err == nil && d != dirs[0] {
|
||||
dirs = append([]string{d}, dirs...)
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
for _, name := range codeInstructionFiles {
|
||||
b, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil || len(strings.TrimSpace(string(b))) == 0 {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(string(b))
|
||||
if r := []rune(s); len(r) > codeInstructionsMax {
|
||||
s = string(r[:codeInstructionsMax]) + "\n…[tronqué — lis le fichier pour la suite]"
|
||||
}
|
||||
rel := name
|
||||
if r, err := filepath.Rel(agentCwd(), filepath.Join(dir, name)); err == nil {
|
||||
rel = filepath.ToSlash(r)
|
||||
}
|
||||
return Message{Role: "system", Content: codeInstructionsPrefix + " (" + rel + ") — follow them:\n\n" + s}, true
|
||||
}
|
||||
}
|
||||
return Message{}, false
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AGENTS.md du dépôt cloné part avec le contexte en mode Code, déplacé dans le
|
||||
// premier message utilisateur (préfixe système intact).
|
||||
func TestConsignesDuDepot(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
if _, ok := codeInstructionsMessage(Caps{Code: true}); ok {
|
||||
t.Fatal("consignes trouvées dans un dossier vide")
|
||||
}
|
||||
repo := filepath.Join(agentCwd(), "projet")
|
||||
_ = os.MkdirAll(filepath.Join(repo, ".git"), 0o755)
|
||||
if err := os.WriteFile(filepath.Join(repo, "AGENTS.md"), []byte("Build : make test\n"+strings.Repeat("x", 5000)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := codeInstructionsMessage(Caps{}); ok {
|
||||
t.Fatal("consignes injectées hors mode Code")
|
||||
}
|
||||
m, ok := codeInstructionsMessage(Caps{Code: true})
|
||||
s := msgText(m)
|
||||
if !ok || !strings.Contains(s, "make test") || !strings.Contains(s, "projet/AGENTS.md") || !strings.Contains(s, "tronqué") {
|
||||
t.Fatalf("message = %.200q", s)
|
||||
}
|
||||
out := normalizeSystemMessages([]Message{{Role: "system", Content: "base"}, m, {Role: "user", Content: "fais-le"}})
|
||||
if msgText(out[0]) != "base" || !strings.Contains(msgText(out[1]), "make test") {
|
||||
t.Fatalf("consignes restées dans le système : %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Le schéma de write annonce le chemin AVANT le contenu.
|
||||
func TestSchemaWriteCheminDAbord(t *testing.T) {
|
||||
b, _ := json.Marshal(writeTool().Function.Parameters)
|
||||
s := string(b)
|
||||
if i, j := strings.Index(s, `"file"`), strings.Index(s, `"content"`); i < 0 || j < 0 || i > j {
|
||||
t.Fatalf("ordre des propriétés : %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// write sur un fichier existant jamais lu : refusé dès que le chemin est
|
||||
// complet, sans attendre le contenu ; le fichier reste intact.
|
||||
func TestPreVolCoupeLEcritureRefusee(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
cible := filepath.Join(agentCwd(), "main.go")
|
||||
if err := os.WriteFile(cible, []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int32
|
||||
var second []Message
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
chunk := func(args string) {
|
||||
esc := strings.ReplaceAll(args, `"`, `\"`)
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"write","arguments":"` + esc + `"}}]}}]}` + "\n\n"))
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
chunk(`{"file":"main.go",`)
|
||||
chunk(`"content":"package main // début`)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
chunk(` FIN-JAMAIS-LUE"}`)
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
second = body.Messages
|
||||
_, _ = w.Write([]byte(sseChunk("je lis d'abord")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var results []string
|
||||
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "réécris main.go"}}, 0.7, Caps{Agent: true, Code: true}, func(ev StreamEvent) bool {
|
||||
if ev.ToolUsed != nil && ev.ToolUsed.Done {
|
||||
results = append(results, ev.ToolUsed.Result)
|
||||
}
|
||||
if ev.ToolUsed != nil && strings.Contains(ev.ToolUsed.Body, "FIN-JAMAIS-LUE") {
|
||||
t.Error("le contenu a continué d'arriver après le refus")
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || !strings.Contains(results[0], "[refusé]") || !strings.Contains(results[0], "interrompue") {
|
||||
t.Fatalf("résultats = %q", results)
|
||||
}
|
||||
if b, _ := os.ReadFile(cible); string(b) != "package main\n" {
|
||||
t.Fatalf("fichier modifié : %q", b)
|
||||
}
|
||||
if len(second) < 2 || second[len(second)-1].Role != "tool" || !strings.Contains(msgText(second[len(second)-1]), "[refusé]") {
|
||||
t.Fatalf("le refus n'a pas été rendu au modèle : %+v", second)
|
||||
}
|
||||
}
|
||||
+41
-13
@@ -14,7 +14,9 @@ package loki
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
|
||||
@@ -29,6 +31,10 @@ var defaultRetryPatterns = []string{
|
||||
`\bfunctions\.\w+\s*\(`,
|
||||
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
|
||||
`\[TOOL_REQUEST\]`,
|
||||
// Format XML de Qwen3-Coder, quand le gabarit du moteur ne l'a pas converti
|
||||
// en tool_calls (OpenFox 2.0.15x) : <function=bash><parameter=command>…
|
||||
`<function=\w+>`,
|
||||
`<parameter=\w+>`,
|
||||
}
|
||||
|
||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||||
@@ -67,18 +73,40 @@ func compileRetryPatterns() []*regexp.Regexp {
|
||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||||
// écrit en toutes lettres au lieu d'être émis par le protocole.
|
||||
func textualToolCall(content string) bool {
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range retryPatterns() {
|
||||
if re.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return textualToolCallSnippet(content) != ""
|
||||
}
|
||||
|
||||
// retryCorrective : le message réinjecté pour relancer le tour.
|
||||
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
|
||||
"Tool calls must go through the tool-call protocol, never in the answer text. " +
|
||||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||||
// textualToolCallSnippet renvoie l'extrait fautif (le motif reconnu et un peu
|
||||
// de ce qui le suit), "" si aucun. Cité dans la consigne corrective : le
|
||||
// modèle voit CE qu'il a mal écrit au lieu d'une remontrance abstraite.
|
||||
func textualToolCallSnippet(content string) string {
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
for _, re := range retryPatterns() {
|
||||
if loc := re.FindStringIndex(content); loc != nil {
|
||||
end := loc[1] + 80
|
||||
if end > len(content) {
|
||||
end = len(content)
|
||||
}
|
||||
for end < len(content) && !utf8.RuneStart(content[end]) {
|
||||
end++
|
||||
}
|
||||
return strings.TrimSpace(content[loc[0]:end])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// maxPatternRetries : relances « appel écrit en texte » consécutives. Le
|
||||
// compteur repart à zéro après chaque appel d'outil réussi (OpenFox) : un long
|
||||
// tour d'agent peut rater la syntaxe plusieurs fois, mais pas en boucle.
|
||||
const maxPatternRetries = 3
|
||||
|
||||
// retryCorrective : le message réinjecté pour relancer le tour, avec l'extrait
|
||||
// fautif.
|
||||
func retryCorrective(snippet string) string {
|
||||
return "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n" + snippet +
|
||||
"\nTool calls must go through the tool-call protocol, never in the answer text. " +
|
||||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Les appels d'outils écrits en texte doivent être détectés…
|
||||
func TestAppelOutilTextuelDetecte(t *testing.T) {
|
||||
@@ -32,3 +42,60 @@ func TestReponseNormaleNonRelancee(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format XML de Qwen3-Coder resté en texte : détecté, et l'extrait est cité.
|
||||
func TestAppelOutilQwenXMLDetecte(t *testing.T) {
|
||||
s := "Je regarde.\n<function=bash>\n<parameter=command>ls -la</parameter>\n</function>"
|
||||
snip := textualToolCallSnippet(s)
|
||||
if snip == "" || !strings.HasPrefix(snip, "<function=bash>") {
|
||||
t.Fatalf("extrait = %q", snip)
|
||||
}
|
||||
if textualToolCall("Un <div class=\"x\"> et un <param name=\"a\"> en HTML.") {
|
||||
t.Fatal("HTML ordinaire pris pour un appel d'outil")
|
||||
}
|
||||
}
|
||||
|
||||
// Le faux appel est repéré PENDANT le flux : la génération est coupée (la suite
|
||||
// n'arrive jamais à l'écran) et la relance cite l'extrait fautif.
|
||||
func TestAppelTextuelCoupeLeFlux(t *testing.T) {
|
||||
testHome(t)
|
||||
var n int32
|
||||
var second string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
_, _ = w.Write([]byte(sseChunk("Je lance <tool_call>")))
|
||||
w.(http.Flusher).Flush()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(sseChunk("FIN-INUTILE")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
second = msgText(body.Messages[len(body.Messages)-1])
|
||||
_, _ = w.Write([]byte(sseChunk("ok")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var content strings.Builder
|
||||
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "liste"}}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
|
||||
content.WriteString(ev.Content)
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(content.String(), "FIN-INUTILE") {
|
||||
t.Fatal("le flux n'a pas été coupé sur l'appel écrit en texte")
|
||||
}
|
||||
if !strings.Contains(second, "<tool_call>") || !strings.Contains(second, "WRITTEN AS TEXT") {
|
||||
t.Fatalf("relance sans l'extrait fautif : %q", second)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ import (
|
||||
// permettrait au builder de se décerner son propre satisfecit.
|
||||
var subagentRoles = []string{"explorer", "code-reviewer", "planner"}
|
||||
|
||||
// subagentTemperature : voir toolSubagent.
|
||||
const subagentTemperature = 0.6
|
||||
|
||||
func isSubagentRole(r string) bool {
|
||||
for _, x := range subagentRoles {
|
||||
if x == r {
|
||||
@@ -102,7 +105,14 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
|
||||
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 {
|
||||
// Température 0.6 et non 0 : en glouton, Qwen avec réflexion tourne vite en
|
||||
// boucle (OpenFox l'a relevé). Le TEMP du preset, s'il est posé, l'emporte.
|
||||
// Seul le texte écrit APRÈS le dernier outil revient au builder : la
|
||||
// narration entre deux lectures n'est pas le rapport.
|
||||
if _, err := runChat(ctx, msgs, subagentTemperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.ToolUsed != nil {
|
||||
out.Reset()
|
||||
}
|
||||
if ev.Content != "" {
|
||||
out.WriteString(ev.Content)
|
||||
}
|
||||
|
||||
@@ -157,3 +157,28 @@ func TestEditCRLFMultiligne(t *testing.T) {
|
||||
t.Fatalf("résultat inattendu : %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// git_status / git_diff trouvent le dépôt cloné dans un sous-dossier de la
|
||||
// discussion, et demandent de choisir quand il y en a plusieurs.
|
||||
func TestGitRepoDirDetecteLeClone(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
if d, err := gitRepoDir(""); err != nil || d != agentCwd() {
|
||||
t.Fatalf("sans dépôt : %q, %v", d, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(agentCwd(), "projet", ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d, err := gitRepoDir(""); err != nil || filepath.Base(d) != "projet" {
|
||||
t.Fatalf("un clone : %q, %v (ws %s)", d, err, ws)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(agentCwd(), "autre", ".git"), 0o755)
|
||||
if _, err := gitRepoDir(""); err == nil {
|
||||
t.Fatal("deux dépôts : il fallait demander dir")
|
||||
}
|
||||
if d, err := gitRepoDir("autre"); err != nil || filepath.Base(d) != "autre" {
|
||||
t.Fatalf("dir explicite : %q, %v", d, err)
|
||||
}
|
||||
if _, err := gitRepoDir("../dehors"); err == nil {
|
||||
t.Fatal("dir hors de la discussion accepté")
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,19 @@ import (
|
||||
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
|
||||
const codeMaxFixTurns = 2
|
||||
|
||||
// codeMaxContinue : relances du builder qui a rendu la main avec des critères
|
||||
// encore ouverts (« ensuite je vais… ») avant de vérifier quand même. Reprise
|
||||
// du buildAgentNudge d'OpenFox : une passe de vérification sur un travail
|
||||
// inachevé, c'est un prefill complet, le cache KV du fil évincé, et des
|
||||
// « failed » qui ne disent que « pas encore fait ».
|
||||
const codeMaxContinue = 2
|
||||
|
||||
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
|
||||
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
|
||||
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
|
||||
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère), ni si le
|
||||
// tour s'est terminé sur une question à l'utilisateur (asked) : sa réponse
|
||||
// passe avant toute vérification.
|
||||
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int, asked bool) {
|
||||
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil || asked {
|
||||
return
|
||||
}
|
||||
convID := convEnsureActive()
|
||||
@@ -40,6 +49,24 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
|
||||
if caps.Role == "planner" {
|
||||
return
|
||||
}
|
||||
// Le builder vérifie lui-même puis marque « completed » ce qu'il estime
|
||||
// fait. Tant qu'il reste des critères ouverts, on le relance plutôt que de
|
||||
// vérifier un travail inachevé.
|
||||
for n := 0; n < codeMaxContinue; n++ {
|
||||
open := critOpen(list)
|
||||
if len(open) == 0 {
|
||||
break
|
||||
}
|
||||
msg := "Not done yet — these acceptance criteria are still open:\n" + critRender(open) +
|
||||
"\n\nContinue working on them. Once one is done and checked, mark it completed (criteria action=set, status=completed). If you need the user's decision, use ask."
|
||||
if c.runBuilderTurn(ctx, caps, temperature, epoch, msg) || ctx.Err() != nil {
|
||||
return // question posée à l'utilisateur, ou arrêt
|
||||
}
|
||||
list = critList(convID)
|
||||
if critAllPassed(list) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for fix := 0; ; fix++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@@ -59,7 +86,9 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
|
||||
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
|
||||
return
|
||||
}
|
||||
c.runFixTurn(ctx, caps, temperature, epoch, list)
|
||||
if c.runFixTurn(ctx, caps, temperature, epoch, list) {
|
||||
return // le builder attend une réponse de l'utilisateur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,38 +120,59 @@ func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature
|
||||
}
|
||||
|
||||
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
|
||||
// critères en échec. Sa trace est persistée comme un tour ordinaire.
|
||||
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
|
||||
// critères en échec. Sa trace est persistée comme un tour ordinaire. Renvoie
|
||||
// true si le builder a posé une question à l'utilisateur (ask).
|
||||
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) bool {
|
||||
var failed []Criterion
|
||||
for _, cr := range list {
|
||||
if cr.Status != "passed" {
|
||||
failed = append(failed, cr)
|
||||
}
|
||||
}
|
||||
return c.runBuilderTurn(ctx, caps, temperature, epoch, "Verification failed on these criteria:\n"+critRender(failed)+
|
||||
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed. Mark each fixed criterion completed again.")
|
||||
}
|
||||
|
||||
// runBuilderTurn relance le BUILDER sur l'historique normal avec une consigne
|
||||
// (correction après vérification, ou relance sur des critères ouverts). Sa
|
||||
// trace est persistée comme un tour ordinaire. Renvoie true si le builder a
|
||||
// posé une question à l'utilisateur (ask) : la boucle doit alors s'arrêter.
|
||||
func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperature float64, epoch int, instruction string) (asked bool) {
|
||||
c.appendDelta(epoch, map[string]any{"role": "builder"})
|
||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||
|
||||
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
|
||||
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
|
||||
fixMsg := Message{Role: "user", Content: instruction}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.epoch != epoch {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
return false
|
||||
}
|
||||
c.Messages = append(c.Messages, fixMsg)
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
c.mu.Unlock()
|
||||
|
||||
final := msgs
|
||||
// Même contexte que le tour de build : projet (description, index mémoire,
|
||||
// trackers) et consignes du dépôt. Sans eux, la reprise changeait le début
|
||||
// du premier message utilisateur — tout le cache de prompt à refaire.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
var content strings.Builder
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
if ev.ToolUsed != nil {
|
||||
content.Reset() // déjà dans le message tool_calls (voir generate)
|
||||
}
|
||||
if ev.Ask != nil {
|
||||
asked = true
|
||||
}
|
||||
c.forwardStream(ev, epoch)
|
||||
return true
|
||||
})
|
||||
@@ -135,6 +185,7 @@ func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature fl
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
return asked
|
||||
}
|
||||
|
||||
// forwardStream relaie les événements d'un runChat secondaire (vérification,
|
||||
@@ -154,10 +205,18 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
|
||||
}
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
tu["added"] = ev.ToolUsed.Added
|
||||
tu["removed"] = ev.ToolUsed.Removed
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.Ask != nil:
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// moteurScripte : faux llama-server qui répond selon le DERNIER message reçu.
|
||||
// Un message `tool` en queue → texte de clôture ; sinon la règle dont le motif
|
||||
// figure dans le dernier message utilisateur → appel d'outil criteria.
|
||||
func moteurScripte(t *testing.T, regles map[string]string) (port string, vus *[]string) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var log []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
last := body.Messages[len(body.Messages)-1]
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
args := ""
|
||||
if last.Role == "user" {
|
||||
txt := msgText(last)
|
||||
mu.Lock()
|
||||
log = append(log, txt)
|
||||
mu.Unlock()
|
||||
for motif, a := range regles {
|
||||
if strings.Contains(txt, motif) {
|
||||
args = a
|
||||
}
|
||||
}
|
||||
}
|
||||
if args != "" {
|
||||
esc := strings.ReplaceAll(args, `"`, `\"`)
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"criteria","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(sseChunk("ok")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
}
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
return u.Port(), &log
|
||||
}
|
||||
|
||||
// Le builder rend la main avec un critère encore ouvert : on le RELANCE (il le
|
||||
// marque completed) avant de vérifier, au lieu de vérifier un travail
|
||||
// inachevé (repris du buildAgentNudge d'OpenFox).
|
||||
func TestVerificationAttendQueLeBuilderAitFini(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||
port, vus := moteurScripte(t, map[string]string{
|
||||
"Not done yet": `{"action":"set","id":"1","status":"completed"}`,
|
||||
"Verify each non-pass": `{"action":"set","id":1,"status":"passed"}`,
|
||||
})
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
|
||||
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, false)
|
||||
|
||||
if len(*vus) < 2 || !strings.Contains((*vus)[0], "Not done yet") || !strings.Contains((*vus)[1], "Verify each non-pass") {
|
||||
t.Fatalf("ordre des passes = %q, attendu relance du builder PUIS vérification", *vus)
|
||||
}
|
||||
if l := critList(id); len(l) != 1 || l[0].Status != "passed" {
|
||||
t.Fatalf("critère final : %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
// Tour terminé sur une question à l'utilisateur : aucune passe ne part avant
|
||||
// sa réponse.
|
||||
func TestPasDeVerificationApresUneQuestion(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||
port, vus := moteurScripte(t, nil)
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
|
||||
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, true)
|
||||
if len(*vus) != 0 {
|
||||
t.Fatalf("%d requête(s) au moteur alors qu'une question attend l'utilisateur", len(*vus))
|
||||
}
|
||||
}
|
||||
|
||||
// Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien #2.
|
||||
func TestCritereIdEnChaine(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"a", "b"}}, false)
|
||||
if out := toolCriteria(map[string]any{"action": "set", "id": "#2", "status": "completed"}, false); !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("id en chaîne refusé : %s", out)
|
||||
}
|
||||
if l := critList(id); l[1].Status != "completed" || l[0].Status != "pending" {
|
||||
t.Fatalf("mauvais critère modifié : %+v", l)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fauxMoteurOutil : un llama-server de comédie qui, à la PREMIÈRE requête,
|
||||
// répond par un appel d'outil (name/args), puis par un texte à la suivante.
|
||||
func fauxMoteurOutil(t *testing.T, name, args string) (port string, requêtes *int32) {
|
||||
t.Helper()
|
||||
var n int32
|
||||
esc := strings.ReplaceAll(args, `"`, `\"`)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"` + name + `","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(sseChunk("Verdict enregistré.")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
}
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
return u.Port(), &n
|
||||
}
|
||||
|
||||
// La passe de vérification (Role=verifier) doit pouvoir marquer un critère
|
||||
// passed via l'outil criteria — c'est tout son rôle. Vécu : « en mode code,
|
||||
// le système de vérification ne coche plus les critères ».
|
||||
func TestPasseDeVerificationCocheLesCriteres(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||
|
||||
port, n := fauxMoteurOutil(t, "criteria", `{"action":"set","id":1,"status":"passed"}`)
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caps := Caps{Agent: true, Code: true, Role: "verifier"}
|
||||
var tools []string
|
||||
_, err := runChat(context.Background(), []Message{
|
||||
{Role: "system", Content: rolePrompt("verifier")},
|
||||
{Role: "user", Content: "Verify."},
|
||||
}, 0.2, caps, func(ev StreamEvent) bool {
|
||||
if ev.ToolUsed != nil && ev.ToolUsed.Done {
|
||||
tools = append(tools, ev.ToolUsed.Name+" → "+ev.ToolUsed.Result)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *n < 2 {
|
||||
t.Fatalf("%d requête(s) au moteur : l'appel d'outil n'a pas été suivi d'un second tour", *n)
|
||||
}
|
||||
list := critList(id)
|
||||
if len(list) != 1 || list[0].Status != "passed" {
|
||||
t.Fatalf("critère non coché par le vérificateur : %+v — outils : %v", list, tools)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,12 @@ Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une po
|
||||
// inflating decode numbers — what you measure here is close to what you'll
|
||||
// see in real chat at the same context length.
|
||||
func runBench(nPrompt, nPredict int) (*benchResult, error) {
|
||||
// Un bench mesure le moteur LOCAL : sur un preset externe, healthCheck dit
|
||||
// « prêt » sans moteur, et la mesure taperait un port arrêté ou un moteur
|
||||
// resté en vie — attribuée à tort à ce preset.
|
||||
if externalActive() {
|
||||
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
|
||||
}
|
||||
port := LLMPort()
|
||||
if !healthCheck() {
|
||||
return nil, fmt.Errorf("serveur injoignable sur :%d", port)
|
||||
@@ -158,6 +164,24 @@ func loadLastBench() *savedBench {
|
||||
return &sb
|
||||
}
|
||||
|
||||
// benchMatchesPreset : le bench a-t-il été mesuré sur le modèle ACTUEL du
|
||||
// preset ? Les benchs sont rangés par id de preset : sans ce contrôle, un
|
||||
// preset dont le modèle a changé affichait les mesures d'un autre modèle.
|
||||
// Repris d'AJEAN 0.16.3.
|
||||
func benchMatchesPreset(sb savedBench, cfg map[string]string) bool {
|
||||
m := strings.TrimSpace(cfg["MODEL"])
|
||||
return m != "" && sb.Model == filepath.Base(m)
|
||||
}
|
||||
|
||||
// deletePresetBench oublie le bench d'un preset supprimé.
|
||||
func deletePresetBench(id string) {
|
||||
m := loadBenchStore()
|
||||
if _, ok := m[id]; ok {
|
||||
delete(m, id)
|
||||
_ = putJSON(bkState, "bench_presets", m)
|
||||
}
|
||||
}
|
||||
|
||||
// loadBenchStore renvoie les benchmarks par preset (vide s'il n'y en a pas).
|
||||
func loadBenchStore() map[string]savedBench {
|
||||
m := map[string]savedBench{}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Un bench n'est affiché que pour le modèle sur lequel il a été mesuré.
|
||||
func TestBenchMatchesPreset(t *testing.T) {
|
||||
sb := savedBench{Model: "Qwen3-27B-Q4_K_M.gguf"}
|
||||
if !benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Qwen3-27B-Q4_K_M.gguf"}) {
|
||||
t.Fatal("même modèle : le bench doit s'afficher")
|
||||
}
|
||||
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Autre.gguf"}) {
|
||||
t.Fatal("autre modèle : le bench ne doit pas s'afficher")
|
||||
}
|
||||
if benchMatchesPreset(sb, map[string]string{}) {
|
||||
t.Fatal("preset sans modèle : pas de bench")
|
||||
}
|
||||
}
|
||||
@@ -89,3 +89,6 @@ func logBudget(calls, budget, sent int) {
|
||||
fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n",
|
||||
calls, budget, sent)
|
||||
}
|
||||
|
||||
// codeBudgetFactor : multiplicateur du palier en mode Code (voir runChat).
|
||||
const codeBudgetFactor = 3
|
||||
+423
-79
@@ -28,6 +28,9 @@ type Message struct {
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
// Index : position de l'appel dans un flux (delta OpenAI). Absent des
|
||||
// messages de l'historique (nil → omis).
|
||||
Index *int `json:"index,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function ToolCallFunc `json:"function"`
|
||||
@@ -96,9 +99,9 @@ func memAddTool() Tool {
|
||||
Description: "Create a memory page. One topic per page, kebab-case name, first line = title (#). Refuses to overwrite an existing page (use mem_edit).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Page name"},
|
||||
"content": map[string]any{"type": "string", "description": "Markdown, first line = title #"},
|
||||
"properties": orderedProps{
|
||||
{"file", map[string]any{"type": "string", "description": "Page name"}},
|
||||
{"content", map[string]any{"type": "string", "description": "Markdown, first line = title #"}},
|
||||
},
|
||||
"required": []string{"file", "content"},
|
||||
},
|
||||
@@ -114,10 +117,10 @@ func memEditTool() Tool {
|
||||
Description: "Patch a memory page: old → new, old unique in the page. To append, put the current end of the page in old and the extended version in new.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Page name"},
|
||||
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"},
|
||||
"new": map[string]any{"type": "string", "description": "Replacement"},
|
||||
"properties": orderedProps{
|
||||
{"file", map[string]any{"type": "string", "description": "Page name"}},
|
||||
{"old", map[string]any{"type": "string", "description": "Exact text to replace (unique)"}},
|
||||
{"new", map[string]any{"type": "string", "description": "Replacement"}},
|
||||
},
|
||||
"required": []string{"file", "old", "new"},
|
||||
},
|
||||
@@ -133,10 +136,11 @@ func editTool() Tool {
|
||||
Description: "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Path"},
|
||||
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"},
|
||||
"new": map[string]any{"type": "string", "description": "Replacement"},
|
||||
// Ordre voulu : le chemin d'abord (voir orderedProps).
|
||||
"properties": orderedProps{
|
||||
{"file", map[string]any{"type": "string", "description": "Path"}},
|
||||
{"old", map[string]any{"type": "string", "description": "Exact text to replace (unique)"}},
|
||||
{"new", map[string]any{"type": "string", "description": "Replacement"}},
|
||||
},
|
||||
"required": []string{"file", "old", "new"},
|
||||
},
|
||||
@@ -152,9 +156,10 @@ func writeTool() Tool {
|
||||
Description: "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Path"},
|
||||
"content": map[string]any{"type": "string", "description": "Full content"},
|
||||
// Ordre voulu : le chemin d'abord (voir orderedProps).
|
||||
"properties": orderedProps{
|
||||
{"file", map[string]any{"type": "string", "description": "Path"}},
|
||||
{"content", map[string]any{"type": "string", "description": "Full content"}},
|
||||
},
|
||||
"required": []string{"file", "content"},
|
||||
},
|
||||
@@ -244,7 +249,10 @@ func InjectSkills(msgs []Message, caps Caps) []Message {
|
||||
return msgs
|
||||
}
|
||||
prefix := strings.Join(parts, "\n\n")
|
||||
if len(msgs) > 0 && msgs[0].Role == "system" {
|
||||
// Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le
|
||||
// préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait
|
||||
// plus le sortir du bloc système commun (voir isProjectSystem).
|
||||
if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) {
|
||||
existing, _ := msgs[0].Content.(string)
|
||||
merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
|
||||
return merged
|
||||
@@ -287,15 +295,29 @@ func steerSystem(msgs []Message, hint string) []Message {
|
||||
// n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc
|
||||
// sans risque pour tous, et sans effet sur une conversation déjà normale (payload
|
||||
// identique, garanti par test).
|
||||
//
|
||||
// Ce qui dépend du PROJET (description, index mémoire, trackers) ne va PAS dans
|
||||
// ce bloc mais en tête du premier message utilisateur (AJEAN 0.15.8). Sur un
|
||||
// modèle hybride (Qwen3.5+, couches récurrentes), llama.cpp ne sait reprendre un
|
||||
// prompt qu'à un point de sauvegarde, et il n'en pose qu'au DÉBUT des messages
|
||||
// utilisateur. Laissé dans le système, le contexte projet faisait diverger le
|
||||
// prompt au milieu du bloc : changer de projet recalculait tout. Au début du 1er
|
||||
// message user, la divergence tombe pile sur un point de sauvegarde, après le
|
||||
// système commun, qui reste en cache.
|
||||
func normalizeSystemMessages(msgs []Message) []Message {
|
||||
var sys []string
|
||||
var sys, proj []string
|
||||
sawSystem := false
|
||||
rest := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
if s, ok := m.Content.(string); ok {
|
||||
sawSystem = true
|
||||
if strings.TrimSpace(s) != "" {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
if isProjectSystem(m) {
|
||||
proj = append(proj, s)
|
||||
} else {
|
||||
sys = append(sys, s)
|
||||
}
|
||||
continue
|
||||
@@ -310,6 +332,12 @@ func normalizeSystemMessages(msgs []Message) []Message {
|
||||
if !sawSystem {
|
||||
return msgs
|
||||
}
|
||||
if len(proj) > 0 {
|
||||
if !prependToFirstUser(rest, strings.Join(proj, "\n\n")) {
|
||||
// Pas encore de message user (cas théorique) : on les garde en système.
|
||||
sys = append(sys, proj...)
|
||||
}
|
||||
}
|
||||
// Des systèmes existaient mais tous vides : on les a retirés (un système vide
|
||||
// hors position 0 casserait tout autant), sans en réinsérer.
|
||||
if len(sys) == 0 {
|
||||
@@ -320,6 +348,48 @@ func normalizeSystemMessages(msgs []Message) []Message {
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// isProjectSystem : message système propre au projet actif (description, index
|
||||
// mémoire, trackers) ou à la conversation (rappel des pages mémoire lues, posé
|
||||
// au compactage), à sortir du bloc système commun (voir
|
||||
// normalizeSystemMessages) : ce bloc doit rester identique partout pour rester
|
||||
// en cache.
|
||||
func isProjectSystem(m Message) bool {
|
||||
s, ok := m.Content.(string)
|
||||
if !ok || m.Role != "system" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(s, projectContextPrefix) ||
|
||||
strings.HasPrefix(s, memIndexPrefix) ||
|
||||
strings.HasPrefix(s, trackerIndexPrefix) ||
|
||||
strings.HasPrefix(s, memReminderPrefix) ||
|
||||
strings.HasPrefix(s, codeInstructionsPrefix)
|
||||
}
|
||||
|
||||
// prependToFirstUser place ctx en tête du premier message user de msgs (modifié
|
||||
// en place : msgs est déjà une copie, mais ses Content ne sont PAS recopiés —
|
||||
// on remplace donc la valeur, jamais on ne mute une tranche partagée).
|
||||
// Renvoie false s'il n'y a aucun message user.
|
||||
func prependToFirstUser(msgs []Message, ctx string) bool {
|
||||
block := "<project_context>\n" + ctx + "\n</project_context>\n\n"
|
||||
for i, m := range msgs {
|
||||
if m.Role != "user" {
|
||||
continue
|
||||
}
|
||||
switch c := m.Content.(type) {
|
||||
case string:
|
||||
msgs[i].Content = block + c
|
||||
case []any:
|
||||
msgs[i].Content = append([]any{map[string]any{"type": "text", "text": block}}, c...)
|
||||
case []map[string]any:
|
||||
msgs[i].Content = append([]map[string]any{{"type": "text", "text": block}}, c...)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnabledTools returns the tools to advertise on the next inference call.
|
||||
func EnabledTools(caps Caps) []Tool {
|
||||
tools := []Tool{}
|
||||
@@ -474,6 +544,9 @@ type ToolUsedEvent struct {
|
||||
// Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
|
||||
// (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
|
||||
Diff []DiffLine
|
||||
// Added / Removed : nombre RÉEL de lignes ajoutées / retirées. Diff est
|
||||
// tronqué pour l'affichage, on ne peut donc pas recompter à partir de lui.
|
||||
Added, Removed int
|
||||
// Image : chemin (relatif au dossier de travail) d'une image PRODUITE par
|
||||
// l'outil — aujourd'hui la capture de web_screenshot. L'UI l'affiche dans la
|
||||
// bulle de l'outil. Sans ça, la capture n'apparaissait QUE si le modèle
|
||||
@@ -481,26 +554,22 @@ type ToolUsedEvent struct {
|
||||
// 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.
|
||||
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle.
|
||||
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 : identifiant (voir saveToolResult) permettant à l'UI de CHARGER
|
||||
// le résultat complet à la demande via /api/chat/tool-result — le flux ne
|
||||
// transporte que l'aperçu. Vide = Result est déjà complet.
|
||||
ResultID string
|
||||
}
|
||||
|
||||
// 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, 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.
|
||||
// toolPreviewChars borne l'aperçu de résultat d'outil envoyé à l'UI (AJEAN
|
||||
// 0.15.1). Le flux — et son rejeu au rechargement — ne transporte plus que
|
||||
// ça ; le reste se charge au clic sur « voir plus » (tool_results.go).
|
||||
// Avant, chaque résultat partait en entier (jusqu'à 12 000 caractères), et un
|
||||
// long fil d'agent en rejouait des centaines à chaque ouverture.
|
||||
const toolPreviewChars = 1600
|
||||
|
||||
// toolResultPreview renvoie (aperçu, taille réelle en runes, coupé ?).
|
||||
// toolResultPreview renvoie (aperçu, taille réelle en runes, coupé?).
|
||||
func toolResultPreview(s string) (string, int, bool) {
|
||||
r := []rune(s)
|
||||
if len(r) <= toolPreviewChars {
|
||||
@@ -510,18 +579,18 @@ func toolResultPreview(s string) (string, int, bool) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// et, si le résultat est coupé, l'id qui permet de charger le reste. Si
|
||||
// l'enregistrement échoue, on envoie le résultat entier (il reste borné par les
|
||||
// plafonds propres à chaque outil).
|
||||
func fillToolResult(ev *ToolUsedEvent, result string) *ToolUsedEvent {
|
||||
prev, n, cut := toolResultPreview(result)
|
||||
ev.ResultChars = n
|
||||
if cut && tcID != "" {
|
||||
ev.Result = prev
|
||||
ev.ResultID = tcID
|
||||
} else {
|
||||
ev.Result = result
|
||||
ev.Result = result
|
||||
if cut {
|
||||
if id := saveToolResult(result); id != "" {
|
||||
ev.Result = prev
|
||||
ev.ResultID = id
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
@@ -596,22 +665,30 @@ func writeBodyKey(tool string) string {
|
||||
// streaming tool-call arguments JSON, so the UI can show the command being
|
||||
// typed live. Best-effort: it tolerates a truncated tail and basic escapes.
|
||||
func previewArg(args, key string) string {
|
||||
v, _ := previewArgDone(args, key)
|
||||
return v
|
||||
}
|
||||
|
||||
// previewArgDone : comme previewArg, et dit si la valeur est COMPLÈTE (guillemet
|
||||
// fermant reçu) — de quoi agir sur un argument avant la fin du flux.
|
||||
func previewArgDone(args, key string) (string, bool) {
|
||||
i := strings.Index(args, "\""+key+"\"")
|
||||
if i < 0 {
|
||||
return ""
|
||||
return "", false
|
||||
}
|
||||
rest := args[i+len(key)+2:]
|
||||
if j := strings.Index(rest, ":"); j >= 0 {
|
||||
rest = rest[j+1:]
|
||||
} else {
|
||||
return ""
|
||||
return "", false
|
||||
}
|
||||
q := strings.Index(rest, "\"")
|
||||
if q < 0 {
|
||||
return ""
|
||||
return "", false
|
||||
}
|
||||
rest = rest[q+1:]
|
||||
var b strings.Builder
|
||||
closed := false
|
||||
for x := 0; x < len(rest); x++ {
|
||||
c := rest[x]
|
||||
if c == '\\' && x+1 < len(rest) {
|
||||
@@ -632,11 +709,12 @@ func previewArg(args, key string) string {
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
b.WriteByte(c)
|
||||
}
|
||||
return b.String()
|
||||
return b.String(), closed
|
||||
}
|
||||
|
||||
// StatsEvent carries llama.cpp's per-completion timing (final chunk).
|
||||
@@ -809,7 +887,12 @@ func toolCallLabel(name string, args map[string]any) string {
|
||||
return label
|
||||
}
|
||||
|
||||
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) {
|
||||
// injectQueued (optionnel, variadique pour ne pas toucher aux appels hors chat)
|
||||
// est consulté à CHAQUE frontière d'étape de la boucle d'outils : il renvoie les
|
||||
// messages utilisateur mis en file PENDANT la génération, pour que le modèle les
|
||||
// prenne en compte dans la SUITE de sa réponse (AJEAN 0.14.0) au lieu
|
||||
// d'obliger à arrêter puis relancer. Ajoutés à `messages` ET à `extra`.
|
||||
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
|
||||
var extra []Message
|
||||
tools := EnabledTools(caps)
|
||||
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
|
||||
@@ -843,6 +926,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// dépassement de la fenêtre de contexte après de gros résultats d'outils), on
|
||||
// compacte l'historique en vol et on rejoue le tour — une seule fois.
|
||||
compactedRetry := false
|
||||
// Réductions forcées (shrinkToFit) quand le compactage n'a pas suffi.
|
||||
shrinkRetries := 0
|
||||
// Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par
|
||||
// tour, comme les autres filets.
|
||||
effortRetried := false
|
||||
@@ -863,13 +948,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// par rendre la main plutôt que faire attendre.
|
||||
const maxNudges = 2
|
||||
nudgeCount := 0
|
||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
|
||||
// relance par tour, comme le nudge.
|
||||
patternRetried := false
|
||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
|
||||
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
|
||||
patternRetries := 0
|
||||
// Reprises RÉSEAU consécutives (llm_retry_net.go). Remis à zéro dès qu'une
|
||||
// réponse arrive : une boucle d'outils longue retrouve son budget à chaque
|
||||
// itération réussie, un moteur durablement mort finit par rendre la main.
|
||||
netRetries := 0
|
||||
// Reprises après un flux coupé EN COURS de réponse (API externe seulement,
|
||||
// voir après la lecture du flux). Remis à zéro à chaque complétion lue en
|
||||
// entier.
|
||||
const maxStreamRetries = 5
|
||||
streamRetries := 0
|
||||
// Destination des complétions : llama-server local, ou une API OpenAI-compatible
|
||||
// externe si le preset actif en est un (backend_external.go). Résolu UNE FOIS
|
||||
// par tour — une bascule de preset en plein tour est rare, et se rejoue de
|
||||
@@ -881,7 +971,27 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// demande de conclure — de la pression, pas une barrière. Sans ça, un modèle
|
||||
// qui tourne en rond n'avait rien en face de lui sauf le bouton stop.
|
||||
toolRuns, budgetNudges, budget := 0, 0, agentBudget()
|
||||
// Mode Code : lire, éditer, compiler, relancer les tests — un vrai tour de
|
||||
// build dépasse vite 24 appels sans tourner en rond, et le troisième rappel
|
||||
// (« n'appelle plus d'outil ») coupait le travail au milieu. Palier triplé.
|
||||
if caps.Code {
|
||||
budget *= codeBudgetFactor
|
||||
}
|
||||
for iter := 0; ; iter++ {
|
||||
// Ajout en cours de réponse : entre deux étapes (après un appel d'outil ou
|
||||
// une relance), on injecte les messages mis en file par l'utilisateur. Pas
|
||||
// à iter==0 : le message initial du tour est déjà dans `messages`. Ni après
|
||||
// un stop : la boucle repasse ici une fois l'outil interrompu, et le
|
||||
// message en file serait journalisé alors que l'utilisateur a repris la
|
||||
// main (dropQueued l'abandonne à la fin du tour).
|
||||
if iter > 0 && ctx.Err() == nil {
|
||||
for _, inject := range injectQueued {
|
||||
if q := inject(); len(q) > 0 {
|
||||
messages = append(messages, q...)
|
||||
extra = append(extra, q...)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté
|
||||
// llama-server reste valide, seul le nouveau message est à traiter.
|
||||
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
|
||||
@@ -893,7 +1003,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
"model": ep.Model,
|
||||
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
|
||||
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
|
||||
"messages": normalizeSystemMessages(messages),
|
||||
// Les images de l'historique y sont rangées par référence
|
||||
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
|
||||
"messages": expandImageRefs(normalizeSystemMessages(messages)),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
||||
@@ -910,7 +1022,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if reasoningEffort != "" {
|
||||
payload["reasoning_effort"] = reasoningEffort
|
||||
}
|
||||
if reasoningKwargs != nil {
|
||||
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
|
||||
// distante stricte (OpenAI) refuse un argument inconnu (400).
|
||||
if reasoningKwargs != nil && !ep.External {
|
||||
payload["chat_template_kwargs"] = reasoningKwargs
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
@@ -931,6 +1045,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// l'envoyer à api.openai.com serait fuiter un secret chez un tiers en même
|
||||
// temps qu'un 401 garanti. Chaque endpoint porte la sienne.
|
||||
ep.auth(req.Header.Set)
|
||||
tReq := time.Now() // secours des stats quand le serveur n'envoie pas de timings
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
// Rien n'est encore parti à l'écran : le tour est rejouable tel quel.
|
||||
@@ -978,7 +1093,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// Le prompt a peut-être dépassé la fenêtre de contexte : on tente une
|
||||
// compaction en vol et on rejoue le tour (une seule fois) avant tout le
|
||||
// reste. C'est le filet de secours à la Hermes.
|
||||
if compactEnabled() && !compactedRetry {
|
||||
// ⚠️ Seulement si l'erreur est VRAIMENT un débordement de contexte : avant,
|
||||
// n'importe quel refus (appel d'outil mal formé, modèle en chargement,
|
||||
// erreur de template…) résumait ~75 % de la conversation, même courte.
|
||||
if compactEnabled() && !compactedRetry && contextOverflow(msg, messages) {
|
||||
if c, changed := compactMessages(ctx, messages, caps); changed {
|
||||
compactedRetry = true
|
||||
// ⚠️ Journaliser AVANT d'installer le résultat : l'ancien ordre
|
||||
@@ -994,15 +1112,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Compactage impuissant (ou déjà tenté et encore trop long) : réduction
|
||||
// forcée plutôt que de laisser remonter un 400 qui bloque la
|
||||
// conversation (AJEAN 0.17.5).
|
||||
if compactEnabled() && shrinkRetries < 2 && contextOverflow(msg, messages) {
|
||||
if c, changed := shrinkToFit(messages, overflowTokens(msg)); changed {
|
||||
shrinkRetries++
|
||||
logCompact("réduction", overflowTokens(msg), messages, c, changed)
|
||||
messages = c
|
||||
extra = nil
|
||||
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Most common 500 here: llama.cpp couldn't parse a malformed tool call
|
||||
// the model emitted. Retry the turn once without tools so it answers
|
||||
// in plain text rather than leaving the chat dead.
|
||||
if !disableTools && len(tools) > 0 {
|
||||
// in plain text rather than leaving the chat dead. Seulement sur 500 :
|
||||
// un 401, 429 ou 502 (API externe, passerelle) n'a rien à voir avec un
|
||||
// appel mal formé, et couper les outils pour ça cassait le tour d'agent.
|
||||
if resp.StatusCode == http.StatusInternalServerError && !disableTools && len(tools) > 0 {
|
||||
disableTools = true
|
||||
// Nudge the model to answer in plain text from what it already
|
||||
// gathered, so it doesn't immediately re-emit a tool call that
|
||||
// llama.cpp would again fail to parse.
|
||||
messages = steerSystem(messages, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.")
|
||||
messages = steerSystem(messages, "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.")
|
||||
continue
|
||||
}
|
||||
// Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut
|
||||
@@ -1036,8 +1169,36 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ
|
||||
// pour que les consommateurs (terminal, web) aient toujours tout.
|
||||
var stats StatsEvent
|
||||
// Serveur sans `timings` (API tierces) : on mesure nous-mêmes le décodage
|
||||
// entre le premier et le dernier token (voir après la boucle).
|
||||
sawTimings := false
|
||||
var tFirst, tLast time.Time
|
||||
usageGen, genChunks := 0, 0
|
||||
lastPreview := "" // last command preview emitted (to stream the typing)
|
||||
lastBodyLines := -1 // lignes déjà diffusées du corps en cours d'écriture
|
||||
// sentAnswer : du texte de réponse ou un outil est déjà parti vers l'UI pour
|
||||
// cette complétion (une reprise le doublerait). sentReasoning : seul du
|
||||
// raisonnement est parti, qu'on sait retirer (DropReasoning). shown : texte
|
||||
// de réponse réellement affiché, rendu au modèle pour qu'il reprenne après
|
||||
// une coupure. typingTool : appel d'outil en cours d'écriture à l'écran.
|
||||
sentAnswer, sentReasoning := false, false
|
||||
var shown strings.Builder
|
||||
var typingTool *ToolUsedEvent
|
||||
scb := func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
sentAnswer = true
|
||||
shown.WriteString(ev.Content)
|
||||
}
|
||||
if ev.ToolUsed != nil {
|
||||
sentAnswer = true
|
||||
t := *ev.ToolUsed
|
||||
typingTool = &t
|
||||
}
|
||||
if ev.Reasoning != "" {
|
||||
sentReasoning = true
|
||||
}
|
||||
return cb(ev)
|
||||
}
|
||||
// Per-completion reasoning-split state (see reasoningOn comment above).
|
||||
sawReasoningField := false
|
||||
thinkOpen := reasoningOn
|
||||
@@ -1049,6 +1210,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
|
||||
aborted := false
|
||||
// patternHit : flux coupé sur un appel d'outil écrit en texte (voir plus bas).
|
||||
patternHit := false
|
||||
// preflight : write/edit refusé dès son chemin (voir plus bas) ; non-nil
|
||||
// mais vide = chemin déjà vérifié, rien à signaler.
|
||||
var preflight *preflightRefusal
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
@@ -1067,6 +1233,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// garde de choices, sinon `gen_tokens`/`gen_per_second` (decode) sont jetés
|
||||
// et l'UI retombe à « 0 tok/s » à la fin de la génération.
|
||||
if chunk.Timings != nil {
|
||||
sawTimings = true
|
||||
stats.PromptTokens = chunk.Timings.PromptN
|
||||
stats.PromptPerSecond = chunk.Timings.PromptPerSecond
|
||||
stats.PromptMs = chunk.Timings.PromptMs
|
||||
@@ -1074,21 +1241,37 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
stats.GenPerSecond = chunk.Timings.PredictedPerSec
|
||||
stats.GenMs = chunk.Timings.PredictedMs
|
||||
s := stats
|
||||
cb(StreamEvent{Stats: &s})
|
||||
scb(StreamEvent{Stats: &s})
|
||||
}
|
||||
if chunk.Usage != nil && chunk.Usage.CompletionTokens > 0 {
|
||||
usageGen = chunk.Usage.CompletionTokens
|
||||
}
|
||||
if chunk.Usage != nil && chunk.Usage.PromptTokens > 0 {
|
||||
stats.PromptTokensTotal = chunk.Usage.PromptTokens
|
||||
s := stats
|
||||
cb(StreamEvent{Stats: &s})
|
||||
scb(StreamEvent{Stats: &s})
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
ch := chunk.Choices[0]
|
||||
if ch.Delta.Content != "" || ch.Delta.ReasoningContent != "" || len(ch.Delta.ToolCalls) > 0 {
|
||||
now := time.Now()
|
||||
if tFirst.IsZero() {
|
||||
tFirst = now
|
||||
}
|
||||
tLast = now
|
||||
genChunks++
|
||||
}
|
||||
if ch.FinishReason != "" {
|
||||
finishReason = ch.FinishReason
|
||||
}
|
||||
if len(ch.Delta.ToolCalls) > 0 {
|
||||
// Un tool_call n'est retenu que si des outils ont VRAIMENT été annoncés ce
|
||||
// tour-ci (même condition que le payload). Sinon — mode agent coupé, ou
|
||||
// relance sans outils après un 500 — le moteur peut quand même parser un
|
||||
// appel que le modèle a émis de lui-même ; l'exécuter donnait « outil
|
||||
// inconnu », réinjecté puis mis en boucle. On l'ignore (AJEAN 0.13.12).
|
||||
if len(tools) > 0 && !disableTools && len(ch.Delta.ToolCalls) > 0 {
|
||||
// Un appel d'outil clôt le texte : on vide MAINTENANT le reliquat
|
||||
// retenu par la garde « </think> » (voir plus bas). Sinon il n'était
|
||||
// émis qu'en fin de flux, donc APRÈS l'événement d'outil, et l'UI
|
||||
@@ -1098,14 +1281,21 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if thinkOpen && thinkTail.Len() > 0 {
|
||||
tail := thinkTail.String()
|
||||
thinkTail.Reset()
|
||||
if !cb(StreamEvent{Content: tail}) {
|
||||
if !scb(StreamEvent{Content: tail}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for i, tc := range ch.Delta.ToolCalls {
|
||||
// llama.cpp's stream may omit index; fall back to slot i.
|
||||
// Le champ index dit à quel appel appartient ce morceau. Un serveur
|
||||
// qui envoie un morceau par chunk met l'appel n°2 en position 0 du
|
||||
// chunk : se fier à i fusionnait les appels parallèles (noms
|
||||
// écrasés, JSON collés « {…}{…} »). Sans index (certains builds
|
||||
// llama.cpp), on retombe sur la position dans le chunk.
|
||||
idx := i
|
||||
if tc.Index != nil {
|
||||
idx = *tc.Index
|
||||
}
|
||||
cur, ok := toolCalls[idx]
|
||||
if !ok {
|
||||
cur = &ToolCall{Type: "function"}
|
||||
@@ -1161,11 +1351,25 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if grew {
|
||||
lastBodyLines = strings.Count(body, "\n")
|
||||
}
|
||||
if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) {
|
||||
if !scb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Pré-vol (OpenFox 2.0.154) : dès que le chemin d'un write/edit est
|
||||
// complet, on vérifie les gardes du mode Code (fichier lu avant
|
||||
// d'être modifié, chemin permis). Refusé : on coupe la génération
|
||||
// MAINTENANT au lieu de laisser le modèle écrire tout un fichier
|
||||
// qui serait de toute façon rejeté.
|
||||
if caps.Code && preflight == nil && (cur.Function.Name == "write" || cur.Function.Name == "edit") {
|
||||
if file, done := previewArgDone(cur.Function.Arguments, "file"); done && strings.TrimSpace(file) != "" {
|
||||
if msg := codeWriteGuard(caps, file, cur.Function.Name == "write"); msg != "" {
|
||||
preflight = &preflightRefusal{name: cur.Function.Name, id: cur.ID, file: file, msg: msg}
|
||||
break
|
||||
}
|
||||
preflight = &preflightRefusal{} // vérifié : plus besoin de regarder
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1173,7 +1377,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// Backend already separates reasoning — trust it, disable our split.
|
||||
sawReasoningField = true
|
||||
thinkOpen = false
|
||||
if !cb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
|
||||
if !scb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
@@ -1181,10 +1385,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if ch.Delta.Content != "" {
|
||||
assistantContent.WriteString(ch.Delta.Content)
|
||||
if !thinkOpen || sawReasoningField {
|
||||
if !cb(StreamEvent{Content: ch.Delta.Content}) {
|
||||
if !scb(StreamEvent{Content: ch.Delta.Content}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
// Appel d'outil écrit en texte, repéré PENDANT le flux (OpenFox) :
|
||||
// on coupe tout de suite au lieu de laisser le modèle dérouler un
|
||||
// faux appel — parfois un fichier entier — qui ne sera jamais
|
||||
// exécuté. La relance corrective part juste après (voir fin de
|
||||
// boucle). Testé seulement quand le morceau peut ouvrir un motif.
|
||||
if len(tools) > 0 && !disableTools && patternRetries < maxPatternRetries &&
|
||||
strings.ContainsAny(ch.Delta.Content, "<`{[_.") && textualToolCall(assistantContent.String()) {
|
||||
patternHit = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// The prompt opened a <think> block. Stream `content` LIVE as
|
||||
// the answer, holding back only a short tail that could be the
|
||||
@@ -1205,11 +1419,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n")
|
||||
thinkOpen = false
|
||||
thinkTail.Reset()
|
||||
if reason != "" && !cb(StreamEvent{Reasoning: reason}) {
|
||||
if reason != "" && !scb(StreamEvent{Reasoning: reason}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
if after != "" && !cb(StreamEvent{Content: after}) {
|
||||
if after != "" && !scb(StreamEvent{Content: after}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
@@ -1226,7 +1440,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
emit := s[:cut]
|
||||
thinkTail.Reset()
|
||||
thinkTail.WriteString(s[cut:])
|
||||
if !cb(StreamEvent{Content: emit}) {
|
||||
if !scb(StreamEvent{Content: emit}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
@@ -1237,7 +1451,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
}
|
||||
// Flush the held-back tail (never part of a </think>): it's answer text.
|
||||
if !aborted && thinkOpen && thinkTail.Len() > 0 {
|
||||
cb(StreamEvent{Content: strings.TrimLeft(thinkTail.String(), "\r\n")})
|
||||
scb(StreamEvent{Content: strings.TrimLeft(thinkTail.String(), "\r\n")})
|
||||
}
|
||||
// ⚠️ Le flux a-t-il fini, ou CASSÉ ? sc.Scan() renvoie false dans les deux
|
||||
// cas, et l'erreur n'était jamais consultée : une lecture coupée en plein
|
||||
@@ -1250,14 +1464,96 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// arguments tronqués. On refuse donc le tour, en le disant.
|
||||
scanErr := sc.Err()
|
||||
resp.Body.Close()
|
||||
if !sawTimings && !tFirst.IsZero() {
|
||||
// Décodage mesuré ici ; le 1er token tombe dans la lecture du prompt.
|
||||
// Pas de débit de lecture : le serveur a pu réutiliser une partie du
|
||||
// prompt en cache, et total/délai donnerait des chiffres fantaisistes.
|
||||
gen := usageGen
|
||||
if gen == 0 {
|
||||
gen = genChunks // sans usage : un chunk ≈ un token
|
||||
}
|
||||
stats.PromptMs = float64(tFirst.Sub(tReq).Milliseconds())
|
||||
stats.GenTokens = gen
|
||||
stats.GenMs = float64(tLast.Sub(tFirst).Milliseconds())
|
||||
if sec := tLast.Sub(tFirst).Seconds(); sec > 0 && gen > 1 {
|
||||
stats.GenPerSecond = float64(gen-1) / sec
|
||||
}
|
||||
s := stats
|
||||
cb(StreamEvent{Stats: &s})
|
||||
}
|
||||
if aborted {
|
||||
return extra, nil
|
||||
}
|
||||
// Coupure APRÈS le dernier chunk (finish_reason reçu) : la réponse est
|
||||
// complète, seule la fermeture a raté. On la garde telle quelle. Flux
|
||||
// coupé par NOUS sur un appel écrit en texte : pas une panne non plus.
|
||||
if scanErr != nil && (finishReason != "" || patternHit || (preflight != nil && preflight.msg != "")) {
|
||||
scanErr = nil
|
||||
}
|
||||
// Flux coupé vers une API DISTANTE (Wi-Fi, VPN, proxy qui décroche) : on
|
||||
// relance au lieu d'abandonner le tour (AJEAN 0.17.4). Jamais pour le
|
||||
// llama-server local : coupé, il a planté, et rejouer ne ferait que
|
||||
// retarder le message d'erreur.
|
||||
// - Rien de la réponse n'est encore affiché : on rejoue la même requête
|
||||
// (le raisonnement déjà montré est retiré, il va être régénéré).
|
||||
// - Du texte est déjà affiché : on le rend au modèle comme début de sa
|
||||
// réponse, suivi d'un « connexion coupée, continue », et il reprend où
|
||||
// il s'était arrêté. Ces deux messages ne vont que dans la vue modèle
|
||||
// de ce tour : la réponse persistée est le texte complet, reconstitué
|
||||
// par l'appelant à partir du flux. Un appel d'outil à moitié écrit est
|
||||
// jeté : le modèle le réémettra.
|
||||
if scanErr != nil && ctx.Err() == nil && !errors.Is(scanErr, bufio.ErrTooLong) &&
|
||||
ep.External && streamRetries < maxStreamRetries {
|
||||
streamRetries++
|
||||
if sentReasoning && !sentAnswer {
|
||||
cb(StreamEvent{DropReasoning: true})
|
||||
}
|
||||
// Clôt la bulle de l'outil à moitié écrit : il va être réémis, sinon
|
||||
// elle resterait « en cours d'écriture » pour toujours.
|
||||
if typingTool != nil {
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: typingTool.Name, Label: typingTool.Label, Done: true, Result: "(appel interrompu par une coupure réseau, relancé)"}})
|
||||
}
|
||||
logStreamRetry(streamRetries, maxStreamRetries, scanErr)
|
||||
if llmNetBackoff(ctx, streamRetries) == nil {
|
||||
if partial := shown.String(); strings.TrimSpace(partial) != "" {
|
||||
messages = append(messages,
|
||||
Message{Role: "assistant", Content: partial},
|
||||
Message{Role: "user", Content: "The connection was cut during your answer. Continue exactly where you stopped, without repeating what you already wrote."})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if scanErr != nil && ctx.Err() == nil {
|
||||
err := streamCutError(scanErr)
|
||||
cb(StreamEvent{Err: err})
|
||||
return extra, err
|
||||
}
|
||||
// Complétion lue en entier : le budget de reprises vaut par coupure
|
||||
// rapprochée, pas pour tout un long tour d'agent.
|
||||
streamRetries = 0
|
||||
|
||||
// Écriture refusée en plein flux (pré-vol) : l'appel, réduit à son chemin,
|
||||
// entre dans l'historique avec le refus pour résultat — le modèle voit
|
||||
// pourquoi, et la boucle repart pour qu'il lise le fichier d'abord.
|
||||
if preflight != nil && preflight.msg != "" {
|
||||
id := preflight.id
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("call_%d_0", iter)
|
||||
}
|
||||
fileArg, _ := json.Marshal(map[string]string{"file": preflight.file})
|
||||
tc := ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: preflight.name, Arguments: string(fileArg)}}
|
||||
assistant := Message{Role: "assistant", ToolCalls: []ToolCall{tc}}
|
||||
if s := assistantContent.String(); s != "" {
|
||||
assistant.Content = s
|
||||
}
|
||||
result := preflight.msg + " (écriture interrompue avant la fin : rien n'a été modifié)"
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: preflight.name, Label: preflight.file, Done: true, Result: result}})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: id, Content: result}
|
||||
messages = append(messages, assistant, toolMsg)
|
||||
extra = append(extra, assistant, toolMsg)
|
||||
toolRuns++
|
||||
continue
|
||||
}
|
||||
|
||||
// Treat any accumulated tool calls as a tool turn even if the backend set
|
||||
// finish_reason to "stop" instead of "tool_calls" (some llama.cpp builds
|
||||
@@ -1270,6 +1566,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
}
|
||||
sort.Ints(idxs)
|
||||
tcs := make([]ToolCall, 0, len(idxs))
|
||||
// Appels dont les arguments sont du JSON CASSÉ (tronqué, mal formé) : on ne
|
||||
// les exécute pas (voir plus bas). Des arguments vides restent valides :
|
||||
// c'est la forme normale d'un outil sans paramètre.
|
||||
badArgs := map[string]bool{}
|
||||
for i, k := range idxs {
|
||||
tc := *toolCalls[k]
|
||||
if tc.ID == "" {
|
||||
@@ -1284,7 +1584,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// en objet vide (l'appel a de toute façon déjà été exécuté). json.Valid("")
|
||||
// étant faux, ça couvre aussi le cas vide d'origine.
|
||||
if !json.Valid([]byte(tc.Function.Arguments)) {
|
||||
if strings.TrimSpace(tc.Function.Arguments) != "" {
|
||||
badArgs[tc.ID] = true
|
||||
}
|
||||
tc.Function.Arguments = "{}"
|
||||
} else {
|
||||
// Nom ou argument appris d'un autre agent (read_file, old_string…) :
|
||||
// traduit vers l'outil réel, AVANT de ranger l'appel dans
|
||||
// l'historique — le modèle y relit l'appel tel qu'exécuté.
|
||||
repairToolCall(&tc, tools)
|
||||
}
|
||||
tcs = append(tcs, tc)
|
||||
}
|
||||
@@ -1294,6 +1602,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
}
|
||||
messages = append(messages, assistant)
|
||||
extra = append(extra, assistant)
|
||||
// Début des résultats de CETTE étape, que le moteur n'a pas encore
|
||||
// comptés (voir le test de compactage en cours de tour, plus bas).
|
||||
stepStart := len(messages)
|
||||
// Appel émis par le protocole : le budget de relances « appel écrit
|
||||
// en texte » repart à neuf.
|
||||
patternRetries = 0
|
||||
// 2. Execute each tool locally and append a "tool" reply.
|
||||
for _, tc := range tcs {
|
||||
// Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce
|
||||
@@ -1315,6 +1629,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// diff : rempli par les outils d'écriture (edit / mémoire) pour que
|
||||
// l'UI montre les lignes ajoutées et retirées.
|
||||
var diff []DiffLine
|
||||
var diffAdd, diffDel int // vrais totaux (diff est tronqué pour l'UI)
|
||||
// visionImg : partie image_url rendue par see_image, réinjectée après
|
||||
// le résultat de l'outil (un message `tool` ne porte que du texte).
|
||||
var visionImg map[string]any
|
||||
@@ -1322,11 +1637,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// rejoue pas. Les petits modèles réémettent volontiers deux fois la
|
||||
// même écriture ; la rejouer produisait une fausse erreur (« old
|
||||
// introuvable », puisque le remplacement est déjà fait).
|
||||
// Arguments illisibles : l'exécuter avec des arguments vides donnait une
|
||||
// erreur trompeuse (« fichier manquant », « commande vide »). On le dit
|
||||
// tel quel au modèle, pour qu'il renvoie un appel complet.
|
||||
if badArgs[tc.ID] {
|
||||
result = "[erreur] arguments de l'appel illisibles (JSON invalide ou tronqué) : l'outil n'a PAS été exécuté. Renvoie l'appel avec des arguments JSON complets et valides."
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
continue
|
||||
}
|
||||
callKey := tc.Function.Name + "\x00" + tc.Function.Arguments
|
||||
if prev, seen := doneCalls[callKey]; seen && dedupableTool(tc.Function.Name) {
|
||||
repeatCount[callKey]++
|
||||
result = repeatedCallResult(prev, repeatCount[callKey])
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result, tc.ID)})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
@@ -1375,7 +1701,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = "[erreur] " + werr.Error()
|
||||
} else {
|
||||
result = fmt.Sprintf("[ok] page '%s' créée", label)
|
||||
diff = addedDiff(content)
|
||||
diff, diffAdd = addedDiff(content)
|
||||
}
|
||||
case "mem_edit":
|
||||
oldText, _ := args["old"].(string)
|
||||
@@ -1386,7 +1712,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = "[erreur] " + werr.Error()
|
||||
} else {
|
||||
result = fmt.Sprintf("[ok] page '%s' modifiée", label)
|
||||
diff = lineDiff(oldText, newText)
|
||||
diff, diffAdd, diffDel = lineDiff(oldText, newText)
|
||||
}
|
||||
case "write":
|
||||
content, _ := args["content"].(string)
|
||||
@@ -1399,7 +1725,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
} else {
|
||||
result = fileWrite(label, content)
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
diff = addedDiff(content)
|
||||
diff, diffAdd = addedDiff(content)
|
||||
trackerNoteWrite(resolveAgentPath(label))
|
||||
result += lspDiagBlock(resolveAgentPath(label), caps)
|
||||
}
|
||||
@@ -1415,7 +1741,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = fileEdit(label, oldText, newText)
|
||||
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
diff = lineDiff(oldText, newText)
|
||||
diff, diffAdd, diffDel = lineDiff(oldText, newText)
|
||||
trackerNoteWrite(resolveAgentPath(label))
|
||||
result += lspDiagBlock(resolveAgentPath(label), caps)
|
||||
}
|
||||
@@ -1437,7 +1763,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
case "bash_tail":
|
||||
result = toolBashTail(args)
|
||||
case "git_status":
|
||||
result = toolGitStatus(ctx)
|
||||
result = toolGitStatus(ctx, args)
|
||||
case "git_diff":
|
||||
result = toolGitDiff(ctx, args)
|
||||
case "git_clone":
|
||||
@@ -1460,6 +1786,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = refusedCommandResult(reason)
|
||||
break
|
||||
}
|
||||
// Mode Code : « cmd & » laisse un process orphelin, sans sortie
|
||||
// lisible ni moyen de l'arrêter — bash_bg est fait pour ça.
|
||||
if caps.Code && trailingBackground(label) {
|
||||
result = "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash."
|
||||
break
|
||||
}
|
||||
if tgt := agentTargetSlug(); tgt != "" {
|
||||
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
|
||||
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
|
||||
@@ -1520,8 +1852,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if tc.Function.Name == "web_screenshot" {
|
||||
shot = capturedRelPath(result)
|
||||
}
|
||||
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}
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result)})
|
||||
// Plafond pour le MODÈLE seulement : l'UI vient de recevoir le
|
||||
// résultat complet (aperçu + « voir plus »).
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
// Capture d'écran + vision active : on fait SUIVRE l'image elle-même
|
||||
@@ -1559,7 +1893,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// enchaînés), on partait à 60% et on finissait en dépassement — rattrapé au
|
||||
// mieux par le filet réactif sur 500, une seule fois. On re-teste donc ici,
|
||||
// avec le contexte RÉEL du dernier appel (usage.prompt_tokens + généré).
|
||||
if used := stats.PromptTokensTotal + stats.GenTokens; compactWouldTrigger(messages, used) {
|
||||
// + les résultats d'outils de CETTE étape : le moteur ne les a pas encore
|
||||
// comptés. Sans eux, une étape qui lit plusieurs gros fichiers d'un coup
|
||||
// passait de 60 % à plus de 130 % de la fenêtre sans jamais compacter.
|
||||
// Serveur sans usage (base 0) : on laisse 0, compactWouldTrigger estime
|
||||
// alors TOUT l'historique — étape comprise.
|
||||
used := 0
|
||||
if base := stats.PromptTokensTotal + stats.GenTokens; base > 0 {
|
||||
used = base + estimateTokens(messages[stepStart:])
|
||||
}
|
||||
if compactWouldTrigger(messages, used) {
|
||||
yes, no := true, false
|
||||
cb(StreamEvent{Compacting: &yes})
|
||||
c, changed := compactMessages(ctx, messages, caps)
|
||||
@@ -1581,10 +1924,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// (code_retry.go). Le texte fautif reste affiché — le remplacer serait
|
||||
// mentir sur ce qui s'est passé — mais l'historique du modèle garde la
|
||||
// trace ET la correction, donc la vraie réponse suit immédiatement.
|
||||
if !patternRetried && len(tools) > 0 && !disableTools && textualToolCall(assistantContent.String()) {
|
||||
patternRetried = true
|
||||
if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" &&
|
||||
patternRetries < maxPatternRetries && len(tools) > 0 && !disableTools {
|
||||
patternRetries++
|
||||
bad := Message{Role: "assistant", Content: assistantContent.String()}
|
||||
fix := Message{Role: "user", Content: retryCorrective}
|
||||
fix := Message{Role: "user", Content: retryCorrective(snippet)}
|
||||
messages = append(messages, bad, fix)
|
||||
extra = append(extra, bad, fix)
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Flux coupé vers une API EXTERNE après un début de réponse : le tour reprend,
|
||||
// le modèle reçoit le texte déjà affiché suivi d'un « continue », et la suite
|
||||
// s'ajoute à l'écran sans doublon ni erreur.
|
||||
func TestRepriseApresCoupureAPIExterne(t *testing.T) {
|
||||
testHome(t)
|
||||
var n int32
|
||||
var second []Message
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(sseChunk("Bonjour ")))
|
||||
w.(http.Flusher).Flush()
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
second = body.Messages
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(sseChunk("le monde")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
var gotErr error
|
||||
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
||||
if ev.Err != nil {
|
||||
gotErr = ev.Err
|
||||
}
|
||||
content.WriteString(ev.Content)
|
||||
return true
|
||||
})
|
||||
if err != nil || gotErr != nil {
|
||||
t.Fatalf("coupure externe non reprise : err=%v, ev=%v", err, gotErr)
|
||||
}
|
||||
if got := content.String(); got != "Bonjour le monde" {
|
||||
t.Fatalf("texte affiché = %q", got)
|
||||
}
|
||||
if len(second) < 3 || second[len(second)-2].Role != "assistant" || second[len(second)-2].Content != "Bonjour " ||
|
||||
second[len(second)-1].Role != "user" {
|
||||
t.Fatalf("la reprise n'a pas rendu le début de réponse au modèle : %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
// Deux appels d'outils parallèles livrés chacun en position 0 de son chunk : le
|
||||
// champ index les sépare. Avant, ils fusionnaient en un seul appel aux
|
||||
// arguments collés « {…}{…} ».
|
||||
func TestAppelsParallelesSuiventLIndex(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
var n int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","type":"function","function":{"name":"grep","arguments":"{\"pattern\":\"rien\"}"}}]}}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(sseChunk("fini")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
}
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var done []string
|
||||
extra, err := runChat(context.Background(), []Message{{Role: "user", Content: "cherche"}}, 0.7, Caps{Agent: true, Code: true}, func(ev StreamEvent) bool {
|
||||
if ev.ToolUsed != nil && ev.ToolUsed.Done {
|
||||
done = append(done, ev.ToolUsed.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(done) != 2 || done[0] != "glob" || done[1] != "grep" {
|
||||
t.Fatalf("appels exécutés = %v, attendu [glob grep]", done)
|
||||
}
|
||||
if len(extra) == 0 || len(extra[0].ToolCalls) != 2 {
|
||||
t.Fatalf("message assistant : %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// Serveur sans `timings` (API tierce) : le débit de décodage est mesuré côté
|
||||
// Loki au lieu de rester à zéro.
|
||||
func TestDebitMesureSansTimings(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
for _, s := range []string{"un ", "deux ", "trois"} {
|
||||
_, _ = w.Write([]byte(sseChunk(s)))
|
||||
w.(http.Flusher).Flush()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}` + "\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var last *StatsEvent
|
||||
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "compte"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
||||
if ev.Stats != nil {
|
||||
last = ev.Stats
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if last == nil || last.GenTokens != 3 || last.GenPerSecond <= 0 {
|
||||
t.Fatalf("stats sans timings = %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// Un refus qui n'a rien d'un appel d'outil mal formé (401 d'une API externe)
|
||||
// remonte tel quel : avant, n'importe quel statut coupait les outils et
|
||||
// rejouait le tour, masquant la vraie cause derrière une réponse sans outils.
|
||||
func TestRefusAPIHors500NeCoupePasLesOutils(t *testing.T) {
|
||||
testHome(t)
|
||||
var n int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&n, 1)
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err == nil || !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("erreur = %v, attendu le 401 de l'API", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&n); got != 1 {
|
||||
t.Fatalf("%d requêtes : un 401 a été rejoué sans outils", got)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ const (
|
||||
llmNetMaxWait = 5 * time.Second
|
||||
)
|
||||
|
||||
// wsaECONNREFUSED : la connexion refusée telle que Windows la rend (WSA 10061).
|
||||
// syscall.ECONNREFUSED n'y est qu'une valeur inventée par Go, jamais renvoyée
|
||||
// par le système ; ailleurs, 10061 n'est le numéro d'aucune erreur.
|
||||
const wsaECONNREFUSED = syscall.Errno(10061)
|
||||
|
||||
// llmRetryableErr : cette erreur de transport vaut-elle une nouvelle tentative ?
|
||||
//
|
||||
// Un ctx déjà fini n'en vaut JAMAIS une : c'est le /stop de l'utilisateur ou la
|
||||
@@ -57,7 +62,7 @@ func llmRetryableErr(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, syscall.ECONNREFUSED): // moteur en cours de démarrage
|
||||
case errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, wsaECONNREFUSED): // moteur en cours de démarrage
|
||||
return true
|
||||
case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return true
|
||||
@@ -122,3 +127,9 @@ func llmNetBackoff(ctx context.Context, n int) error {
|
||||
func logLLMRetry(attempt int, cause string) {
|
||||
fmt.Fprintf(os.Stderr, "[llm] %s — nouvelle tentative %d/%d\n", cause, attempt, llmNetRetries)
|
||||
}
|
||||
|
||||
// logStreamRetry trace la reprise d'un flux coupé EN COURS de réponse (API
|
||||
// externe) : budget distinct de celui d'avant le flux, voir runChat.
|
||||
func logStreamRetry(attempt, max int, cause error) {
|
||||
fmt.Fprintf(os.Stderr, "[llm] flux coupé en cours de réponse (%v) — reprise %d/%d\n", cause, attempt, max)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -145,10 +146,35 @@ func lspDiagnosticsFor(absPath string) string {
|
||||
if len(diags) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Erreurs d'abord, puis avertissements, puis le reste (OpenFox) : au-delà
|
||||
// de lspMaxDiags on tronque, et des indices de style passaient devant
|
||||
// l'erreur de compilation qui comptait. Le compte annonce le total.
|
||||
rank := func(s int) int {
|
||||
if s <= 0 {
|
||||
return 1 // sévérité absente : traitée comme une erreur
|
||||
}
|
||||
return s
|
||||
}
|
||||
sort.SliceStable(diags, func(i, j int) bool { return rank(diags[i].Severity) < rank(diags[j].Severity) })
|
||||
nErr, nWarn := 0, 0
|
||||
for _, d := range diags {
|
||||
switch rank(d.Severity) {
|
||||
case 1:
|
||||
nErr++
|
||||
case 2:
|
||||
nWarn++
|
||||
}
|
||||
}
|
||||
total := len(diags)
|
||||
if len(diags) > lspMaxDiags {
|
||||
diags = diags[:lspMaxDiags]
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d erreur(s), %d avertissement(s)", nErr, nWarn)
|
||||
if total > len(diags) {
|
||||
fmt.Fprintf(&b, " — %d premiers sur %d", len(diags), total)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, d := range diags {
|
||||
sev := "info"
|
||||
switch d.Severity {
|
||||
|
||||
@@ -50,6 +50,18 @@ const (
|
||||
mcpCallTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// mcpOutputCap borne la sortie d'un outil MCP renvoyée au modèle. L'amont
|
||||
// (AJEAN 0.15.2) la transmet désormais EN ENTIER, comme une page mémoire : une
|
||||
// réponse MCP (un ticket, un document, une requête) coupée à 12 000 caractères
|
||||
// laissait le modèle travailler sur un début. On s'en rapproche sans renoncer
|
||||
// à tout garde-fou — sur 65k de contexte, une seule réponse géante noierait la
|
||||
// fenêtre et déclencherait un compactage en plein raisonnement : le plafond
|
||||
// suit la fenêtre, ~1 caractère par token de contexte, soit environ un quart
|
||||
// de celle-ci (≈4 caractères par token). Jamais sous l'ancien plafond.
|
||||
func mcpOutputCap() int {
|
||||
return max(12000, ctxWindow())
|
||||
}
|
||||
|
||||
// mcpSession est une connexion vivante à un serveur MCP.
|
||||
//
|
||||
// L'entrée est publiée dans le pool AVANT que la connexion aboutisse, pour qu'un
|
||||
@@ -396,12 +408,10 @@ 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, lim := []rune(out), mcpOutputCap(); len(r) > lim {
|
||||
out = string(r[:lim]) + fmt.Sprintf("\n…[tronqué : %d caractères au total, %d gardés]", len(r), lim)
|
||||
}
|
||||
if res.IsError {
|
||||
return "[l'outil a renvoyé une erreur]\n" + out, nil
|
||||
}
|
||||
|
||||
@@ -219,3 +219,45 @@ func TestSnapshotRestore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Activer le chiffrement ne doit pas rendre illisibles les clés-pointeurs du
|
||||
// bucket des discussions (discussion active, mode Code) ni les critères. Une
|
||||
// version précédente les chiffrait : la discussion active devenait
|
||||
// introuvable. Celles déjà chiffrées sont réparées au déverrouillage.
|
||||
func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
id := convEnsureActive()
|
||||
if err := setConvMode(id, "code"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
|
||||
t.Fatalf("EnableMemEncryption: %v", err)
|
||||
}
|
||||
if got := getStr(bkChat, ckActive); got != id {
|
||||
t.Fatalf("discussion active illisible après chiffrement : %q", got)
|
||||
}
|
||||
if !convCodeMode() {
|
||||
t.Fatal("mode Code perdu après chiffrement")
|
||||
}
|
||||
if l := critList(id); len(l) != 1 {
|
||||
t.Fatalf("critères perdus après chiffrement : %+v", l)
|
||||
}
|
||||
if !looksEncrypted(getBytes(bkChat, critKey(id))) {
|
||||
t.Fatal("critères en clair alors que les discussions sont chiffrées")
|
||||
}
|
||||
if !bucketFullyEncrypted(bkChat) {
|
||||
t.Fatal("les clés-pointeurs en clair font croire à un chiffrement incomplet")
|
||||
}
|
||||
// Base d'une version précédente : le pointeur a été chiffré.
|
||||
enc, err := encodeMemContent([]byte(id))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = putBytes(bkChat, ckActive, enc)
|
||||
healPlainStoreKeys()
|
||||
if got := getStr(bkChat, ckActive); got != id {
|
||||
t.Fatalf("pointeur chiffré non réparé : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Chaque projet garde son propre mode mémoire ; un projet sans réglage retombe
|
||||
// sur l'ancien global MEM_MODE.
|
||||
func TestMemModePerProject(t *testing.T) {
|
||||
testHome(t)
|
||||
ensureDefaultProject()
|
||||
if err := SetConfigKey("MEM_MODE", "ondemand"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memMode() != MemOnDemand {
|
||||
t.Fatalf("sans réglage propre, repli global attendu, got %q", memMode())
|
||||
}
|
||||
b, err := createProject("Chantier")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setActiveProject(b.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setMemMode(MemSearchFirst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memMode() != MemSearchFirst {
|
||||
t.Fatalf("mode du projet actif attendu, got %q", memMode())
|
||||
}
|
||||
if err := setActiveProject(defaultProjectSlug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memMode() != MemOnDemand {
|
||||
t.Fatalf("l'autre projet ne doit pas hériter du réglage, got %q", memMode())
|
||||
}
|
||||
if setProjectMemMode(b.Slug, "n'importe quoi") == nil {
|
||||
t.Fatal("un mode inconnu doit être refusé")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ package loki
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errStoreLocked : écriture chiffrée demandée alors que la DEK n'est pas en RAM.
|
||||
@@ -90,12 +91,46 @@ func getStoreJSON(bucket, key string, dst any) bool {
|
||||
return json.Unmarshal(b, dst) == nil
|
||||
}
|
||||
|
||||
// plainStoreKey : clés d'un bucket chiffré qui restent EN CLAIR, parce que le
|
||||
// code les lit et les écrit avec getStr/putStr — des pointeurs et drapeaux, pas
|
||||
// des données : la discussion active (son id), le mode Chat|Code d'une
|
||||
// discussion, la puce « passer en mode Code » déjà montrée. Chiffrées par
|
||||
// reencryptBucket, elles revenaient à la lecture comme du charabia : la
|
||||
// discussion active devenait introuvable, le mode Code et ses critères
|
||||
// disparaissaient dès l'activation du chiffrement.
|
||||
func plainStoreKey(bucket, key string) bool {
|
||||
if bucket != bkChat {
|
||||
return false
|
||||
}
|
||||
return key == ckActive || strings.HasPrefix(key, "mode:") || strings.HasPrefix(key, "hinted:")
|
||||
}
|
||||
|
||||
// healPlainStoreKeys remet en clair les clés-pointeurs qu'une version
|
||||
// précédente avait chiffrées (voir plainStoreKey). À appeler dès que la DEK est
|
||||
// en RAM, AVANT de recharger la discussion active. Sans DEK, ne fait rien.
|
||||
func healPlainStoreKeys() {
|
||||
if !memUnlocked() {
|
||||
return
|
||||
}
|
||||
for _, bucket := range encryptedBuckets {
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if !plainStoreKey(bucket, k) || !looksEncrypted(raw) {
|
||||
continue
|
||||
}
|
||||
if plain, err := decodeMemContent(raw); err == nil {
|
||||
_ = putBytes(bucket, k, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if looksEncrypted(raw) || plainStoreKey(bucket, k) {
|
||||
continue
|
||||
}
|
||||
if err := putStoreBytes(bucket, k, raw); err != nil {
|
||||
@@ -113,7 +148,10 @@ func reencryptBucket(bucket string) error {
|
||||
// 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) {
|
||||
for k, v := range allKV(bucket) {
|
||||
if plainStoreKey(bucket, k) {
|
||||
continue
|
||||
}
|
||||
if v != "" && !looksEncrypted([]byte(v)) {
|
||||
return false
|
||||
}
|
||||
@@ -125,16 +163,18 @@ func bucketFullyEncrypted(bucket string) bool {
|
||||
// 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 trackers (bkTracker, 3ᵉ type de mémoire), et les résultats complets des
|
||||
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil).
|
||||
//
|
||||
// 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}
|
||||
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes}
|
||||
|
||||
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
|
||||
func reencryptChatStores() error {
|
||||
healPlainStoreKeys()
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := reencryptBucket(b); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le contexte PROJET quitte le bloc système pour la tête du 1er message user :
|
||||
// le système commun reste identique d'un projet à l'autre (cache de prompt), et
|
||||
// l'historique d'origine n'est pas muté.
|
||||
func TestNormalizeMovesProjectContextToFirstUser(t *testing.T) {
|
||||
in := []Message{
|
||||
{Role: "system", Content: "Préambule commun"},
|
||||
{Role: "system", Content: projectContextPrefix + " — projet A"},
|
||||
{Role: "system", Content: memIndexPrefix + " for project A"},
|
||||
{Role: "user", Content: "bonjour"},
|
||||
{Role: "assistant", Content: "salut"},
|
||||
{Role: "user", Content: "suite"},
|
||||
}
|
||||
ref := append([]Message(nil), in...)
|
||||
out := normalizeSystemMessages(in)
|
||||
if len(out) != 4 || out[0].Content != "Préambule commun" {
|
||||
t.Fatalf("système commun attendu seul en tête : %#v", out)
|
||||
}
|
||||
first, _ := out[1].Content.(string)
|
||||
if !strings.HasPrefix(first, "<project_context>\n"+projectContextPrefix) ||
|
||||
!strings.Contains(first, memIndexPrefix) || !strings.HasSuffix(first, "bonjour") {
|
||||
t.Fatalf("contexte projet absent du 1er message user : %q", first)
|
||||
}
|
||||
if out[3].Content != "suite" {
|
||||
t.Fatal("seul le PREMIER message user reçoit le contexte")
|
||||
}
|
||||
if !reflect.DeepEqual(in, ref) {
|
||||
t.Fatal("l'entrée a été mutée (interdit)")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans prompt de preset, le premier système est un message projet : le préambule
|
||||
// ne doit pas l'absorber (il perdrait son préfixe et resterait dans le système).
|
||||
func TestInjectSkillsKeepsProjectMessageSeparate(t *testing.T) {
|
||||
in := []Message{
|
||||
{Role: "system", Content: projectContextPrefix + " — projet A"},
|
||||
{Role: "user", Content: "bonjour"},
|
||||
}
|
||||
out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}))
|
||||
if s, _ := out[0].Content.(string); out[0].Role != "system" || strings.Contains(s, projectContextPrefix) {
|
||||
t.Fatalf("le contexte projet ne doit pas rester dans le système : %#v", out[0])
|
||||
}
|
||||
if u, _ := out[1].Content.(string); !strings.Contains(u, projectContextPrefix) {
|
||||
t.Fatalf("contexte projet attendu dans le 1er message user : %#v", out[1])
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ type Project struct {
|
||||
// de conversation (projectContextMessage) pour qu'elle sache d'emblée sur quoi
|
||||
// elle travaille, sans avoir à le lui réexpliquer à chaque nouvelle discussion.
|
||||
Desc string `json:"desc,omitempty"`
|
||||
// MemMode = mode mémoire PROPRE à ce projet (off/ondemand/always/search).
|
||||
// Vide = pas encore réglé → repli sur l'ancien global MEM_MODE (voir memMode).
|
||||
MemMode string `json:"mem_mode,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -186,6 +189,36 @@ func projectDesc(slug string) string {
|
||||
// setProjectDesc enregistre la description d'un projet, tronquée à une taille
|
||||
// raisonnable : c'est un contexte injecté à CHAQUE conversation, pas un cahier des
|
||||
// charges. Une chaîne vide efface la description.
|
||||
// projectMemMode renvoie le mode mémoire propre à un projet ("" si non réglé, ce
|
||||
// qui déclenche le repli global dans memMode).
|
||||
func projectMemMode(slug string) string {
|
||||
for _, p := range listProjects() {
|
||||
if p.Slug == slug {
|
||||
return p.MemMode
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// setProjectMemMode enregistre le mode mémoire d'un projet. Une valeur vide
|
||||
// efface le réglage propre (retour au repli global).
|
||||
func setProjectMemMode(slug, mode string) error {
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
switch mode {
|
||||
case "", "off", "ondemand", "always", "search":
|
||||
default:
|
||||
return fmt.Errorf("mode mémoire invalide: %s", mode)
|
||||
}
|
||||
list := listProjects()
|
||||
for i := range list {
|
||||
if list[i].Slug == slug {
|
||||
list[i].MemMode = mode
|
||||
return saveProjects(list)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("projet introuvable")
|
||||
}
|
||||
|
||||
func setProjectDesc(slug, desc string) error {
|
||||
desc = strings.TrimSpace(desc)
|
||||
const max = 2000
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.13.0"
|
||||
const Version = "0.14.0"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
|
||||
func Main() {
|
||||
|
||||
@@ -45,11 +45,22 @@ func loc(tz string) *time.Location {
|
||||
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
|
||||
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
|
||||
// - expression cron 5 champs.
|
||||
// - "@once 2026-10-01 16:54" : une seule fois, à cette date et heure murale.
|
||||
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
|
||||
s := strings.TrimSpace(schedule)
|
||||
if s == "" {
|
||||
return time.Time{}, fmt.Errorf("fréquence vide")
|
||||
}
|
||||
if rest, ok := cutPrefix(s, "@once"); ok {
|
||||
at, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(rest), loc(tz))
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)")
|
||||
}
|
||||
if !at.After(from) {
|
||||
return time.Time{}, fmt.Errorf("%s est déjà passé (il est %s)", at.Format("2006-01-02 15:04"), from.In(loc(tz)).Format("2006-01-02 15:04"))
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
if rest, ok := cutPrefix(s, "@every"); ok {
|
||||
return nextEvery(strings.TrimSpace(rest), tz, from)
|
||||
}
|
||||
@@ -249,3 +260,9 @@ func cutPrefix(s, prefix string) (string, bool) {
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isOnce : tâche à exécution unique (« @once … »), désactivée après son passage.
|
||||
func isOnce(schedule string) bool {
|
||||
_, ok := cutPrefix(strings.TrimSpace(schedule), "@once")
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// « @once » : une date future est acceptée, une date passée refusée.
|
||||
func TestScheduleOnce(t *testing.T) {
|
||||
from := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
at, err := nextAfter("@once 2026-10-02 12:30", "UTC", from)
|
||||
if err != nil || !at.Equal(from.Add(30*time.Minute)) {
|
||||
t.Fatalf("@once futur : %v, %v", at, err)
|
||||
}
|
||||
if _, err := nextAfter("@once 2026-10-02 11:00", "UTC", from); err == nil {
|
||||
t.Fatal("@once passé accepté")
|
||||
}
|
||||
if !isOnce(" @ONCE 2026-10-02 12:30") || isOnce("@every 2h") {
|
||||
t.Fatal("isOnce se trompe")
|
||||
}
|
||||
}
|
||||
|
||||
// L'IA crée un rappel « dans 20 minutes » ou « à HH:MM » sans écrire de cron,
|
||||
// dans le fuseau du navigateur ; une fois passée, la tâche se désactive.
|
||||
func TestTacheUniqueParLIA(t *testing.T) {
|
||||
testHome(t)
|
||||
rememberUserTZ("Europe/Paris")
|
||||
if got := defaultTaskTZ(); got != "Europe/Paris" {
|
||||
t.Fatalf("fuseau retenu = %q", got)
|
||||
}
|
||||
rememberUserTZ("Pas/Un_Fuseau") // invalide : ignoré
|
||||
if got := defaultTaskTZ(); got != "Europe/Paris" {
|
||||
t.Fatalf("fuseau invalide retenu : %q", got)
|
||||
}
|
||||
|
||||
out := toolTaskCreate(map[string]any{"name": "rappel", "prompt": "dis bonjour", "in_minutes": float64(20)})
|
||||
if !strings.HasPrefix(out, "[ok]") || !strings.Contains(out, "Europe/Paris") {
|
||||
t.Fatalf("création « dans 20 min » : %s", out)
|
||||
}
|
||||
out = toolTaskCreate(map[string]any{"name": "apéro", "prompt": "rappelle l'apéro", "at": "18:30"})
|
||||
if !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("création « à 18:30 » : %s", out)
|
||||
}
|
||||
if out := toolTaskCreate(map[string]any{"name": "flou", "prompt": "x", "at": "demain"}); !strings.HasPrefix(out, "[erreur]") {
|
||||
t.Fatalf("'at' invalide accepté : %s", out)
|
||||
}
|
||||
|
||||
var once Task
|
||||
for _, tk := range listTasks() {
|
||||
if tk.Name == "rappel" {
|
||||
once = tk
|
||||
}
|
||||
}
|
||||
if !isOnce(once.Schedule) || once.TZ != "Europe/Paris" || once.NextRun == 0 {
|
||||
t.Fatalf("tâche unique mal posée : %+v", once)
|
||||
}
|
||||
want := time.Now().Add(20 * time.Minute)
|
||||
if d := time.UnixMilli(once.NextRun).Sub(want); d > time.Minute || d < -time.Minute {
|
||||
t.Fatalf("prochaine exécution à %v, attendu ≈ %v", time.UnixMilli(once.NextRun), want)
|
||||
}
|
||||
|
||||
recordTaskEnd(once.ID, time.Now(), "bonjour", nil)
|
||||
after, _ := getTask(once.ID)
|
||||
if after.Enabled || after.NextRun != 0 {
|
||||
t.Fatalf("tâche unique encore active après son passage : %+v", after)
|
||||
}
|
||||
}
|
||||
+14
-12
@@ -68,26 +68,25 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
}
|
||||
// La note de tâche (conscience du mode autonome + compte-rendu du passage
|
||||
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : le
|
||||
// préfixe système + outils reste ainsi identique à celui du chat, et le cache
|
||||
// de prompt de llama-server sert aux deux. Une note dans le système faisait
|
||||
// tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s
|
||||
// mesurées en amont pour un simple « salut », AJEAN 0.17.5).
|
||||
if note := taskContextNote(taskName, lastReport); note != "" {
|
||||
prompt = note + "\n\n" + prompt
|
||||
}
|
||||
msgs := []Message{{Role: "user", Content: prompt}}
|
||||
// Même préambule que la vraie génération : consigne personnelle + préambule
|
||||
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
|
||||
// contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de
|
||||
// contexte (conscience du mode autonome + mémoire du passage précédent),
|
||||
// fusionnée dans UN SEUL message système en tête — comme l'exigent les
|
||||
// gabarits stricts (normalizeSystemMessages le garantit de toute façon).
|
||||
// contexte et les mêmes outils qu'en chat.
|
||||
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
|
||||
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
|
||||
// chantier — la tâche voit la mémoire et les trackers qu'elle vise.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
sys := readSysPrompt()
|
||||
note := taskContextNote(taskName, lastReport)
|
||||
switch {
|
||||
case sys != "" && note != "":
|
||||
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, final...)
|
||||
case sys != "":
|
||||
if sys := readSysPrompt(); sys != "" {
|
||||
final = append([]Message{{Role: "system", Content: sys}}, final...)
|
||||
case note != "":
|
||||
final = append([]Message{{Role: "system", Content: note}}, final...)
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
@@ -267,6 +266,9 @@ func recordTaskEnd(id string, start time.Time, report string, err error) {
|
||||
cur.LastDurMs = now.Sub(start).Milliseconds()
|
||||
cur.LastRun = now.UnixMilli()
|
||||
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
|
||||
if isOnce(cur.Schedule) {
|
||||
cur.Enabled, cur.NextRun = false, 0 // passée une fois : terminée
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
|
||||
// « interrompue » sans allumer l'indicateur rouge d'erreur.
|
||||
|
||||
@@ -54,10 +54,12 @@ func taskCreateTool() Tool {
|
||||
"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)"},
|
||||
"schedule": map[string]any{"type": "string", "description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)"},
|
||||
"in_minutes": map[string]any{"type": "integer", "description": "One-time task, N minutes from now"},
|
||||
"at": map[string]any{"type": "string", "description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\""},
|
||||
"enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"},
|
||||
},
|
||||
"required": []string{"name", "schedule"},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -101,11 +103,30 @@ func taskDeleteTool() Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// 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à.
|
||||
// stUserTZKey : fuseau IANA du navigateur de l'utilisateur, envoyé avec chaque
|
||||
// message (voir rememberUserTZ).
|
||||
const stUserTZKey = "user_tz"
|
||||
|
||||
// rememberUserTZ retient le fuseau du navigateur (« Europe/Paris ») s'il est
|
||||
// valide et a changé. Le conteneur tourne en UTC : sans ça, « rappelle-moi à
|
||||
// 17h » tombait deux heures à côté (AJEAN 0.17.5).
|
||||
func rememberUserTZ(tz string) {
|
||||
tz = strings.TrimSpace(tz)
|
||||
if tz == "" || tz == getStr(bkState, stUserTZKey) {
|
||||
return
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err == nil {
|
||||
_ = putStr(bkState, stUserTZKey, tz)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultTaskTZ : le fuseau de l'utilisateur pour ce que le modèle planifie.
|
||||
// Celui du navigateur s'il est connu, sinon celui d'une tâche existante (posé
|
||||
// par l'UI), sinon vide = heure locale du serveur (souvent UTC).
|
||||
func defaultTaskTZ() string {
|
||||
if tz := getStr(bkState, stUserTZKey); tz != "" {
|
||||
return tz
|
||||
}
|
||||
for _, t := range listTasks() {
|
||||
if strings.TrimSpace(t.TZ) != "" {
|
||||
return t.TZ
|
||||
@@ -189,8 +210,30 @@ func toolTaskCreate(args map[string]any) string {
|
||||
prompt := strings.TrimSpace(taskStrArg(args["prompt"]))
|
||||
script := strings.TrimSpace(taskStrArg(args["script"]))
|
||||
schedule := strings.TrimSpace(taskStrArg(args["schedule"]))
|
||||
tz := defaultTaskTZ()
|
||||
// Tâche unique : « dans N minutes » ou « à HH:MM », calculés ICI dans le
|
||||
// fuseau de l'utilisateur (AJEAN 0.17.5). Le modèle n'a plus à jongler avec
|
||||
// les fuseaux ni le cron pour un simple rappel.
|
||||
if schedule == "" {
|
||||
now := time.Now().In(loc(tz))
|
||||
if v, ok := args["in_minutes"].(float64); ok && v >= 1 {
|
||||
schedule = "@once " + now.Add(time.Duration(v)*time.Minute).Format("2006-01-02 15:04")
|
||||
} else if at := strings.TrimSpace(taskStrArg(args["at"])); at != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04", at, loc(tz)); err == nil {
|
||||
schedule = "@once " + t.Format("2006-01-02 15:04")
|
||||
} else if hh, mm, err := parseHHMM(at); err == nil {
|
||||
t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc(tz))
|
||||
if !t.After(now) {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
}
|
||||
schedule = "@once " + t.Format("2006-01-02 15:04")
|
||||
} else {
|
||||
return "[erreur] 'at' invalide : « HH:MM » ou « AAAA-MM-JJ HH:MM »"
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "" || schedule == "" {
|
||||
return "[erreur] name et schedule sont obligatoires"
|
||||
return "[erreur] name obligatoire, et schedule (tâche répétée) ou in_minutes / at (tâche unique)"
|
||||
}
|
||||
kind := ""
|
||||
if script != "" {
|
||||
@@ -202,7 +245,6 @@ func toolTaskCreate(args map[string]any) string {
|
||||
} 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()
|
||||
}
|
||||
@@ -223,7 +265,7 @@ func toolTaskCreate(args map[string]any) string {
|
||||
}
|
||||
when := ""
|
||||
if t.NextRun > 0 && enabled {
|
||||
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04")
|
||||
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04") + " (" + tzLabel(tz) + ")"
|
||||
}
|
||||
return fmt.Sprintf("[ok] tâche '%s' créée (id %s)%s", name, t.ID, when)
|
||||
}
|
||||
@@ -279,3 +321,11 @@ func toolTaskDelete(args map[string]any) string {
|
||||
}
|
||||
return fmt.Sprintf("[ok] tâche '%s' (id %s) supprimée", t.Name, id)
|
||||
}
|
||||
|
||||
// tzLabel : nom du fuseau à montrer au modèle.
|
||||
func tzLabel(tz string) string {
|
||||
if tz == "" {
|
||||
return "heure du serveur"
|
||||
}
|
||||
return tz
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// tool_results.go — résultats COMPLETS des outils, pour le bouton « voir plus ».
|
||||
// Repris d'AJEAN 0.15.5 / 0.15.7. Chiffré comme les discussions quand le
|
||||
// chiffrement mémoire est actif (bkToolRes figure dans encryptedBuckets).
|
||||
//
|
||||
// Le flux n'envoie à l'UI qu'un aperçu (toolPreviewChars) ; le reste se charge
|
||||
// au clic. Il ne peut pas être cherché dans conv.Messages : pendant un tour
|
||||
// d'agent, les messages tool n'y sont versés qu'à la FIN du tour, et après un
|
||||
// compactage les anciens ont disparu de la vue modèle. On garde donc chaque
|
||||
// résultat coupé dans un bucket dédié, sous un id PROPRE (pas le tool_call_id,
|
||||
// que certains parseurs réutilisent d'un tour à l'autre).
|
||||
//
|
||||
// Rangement PAR DISCUSSION : la clé est « <id de discussion>.<aléa> ». Les
|
||||
// résultats vivent aussi longtemps que leur discussion (supprimés avec elle,
|
||||
// voir deleteToolResultsFor) ; les orphelins sont nettoyés de temps en temps.
|
||||
|
||||
const (
|
||||
bkToolRes = "toolres"
|
||||
toolResPruneGap = 200 // nettoyage tenté toutes les N écritures
|
||||
)
|
||||
|
||||
var toolResWrites atomic.Int64
|
||||
|
||||
func toolResID(sid string) string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
if sid = strings.TrimSpace(sid); sid == "" {
|
||||
sid = "nosession"
|
||||
}
|
||||
return sid + "." + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// saveToolResult enregistre un résultat complet de la discussion ACTIVE et
|
||||
// renvoie son id ("" en cas d'échec : l'appelant envoie alors le résultat
|
||||
// entier dans le flux).
|
||||
func saveToolResult(result string) string {
|
||||
id := toolResID(getStr(bkChat, ckActive))
|
||||
// Chiffrement actif mais mémoire verrouillée : putStoreBytes refuse d'écrire
|
||||
// en clair, et l'appelant envoie alors le résultat entier dans le flux.
|
||||
if id == "" || putStoreBytes(bkToolRes, id, []byte(result)) != nil {
|
||||
return ""
|
||||
}
|
||||
if toolResWrites.Add(1)%toolResPruneGap == 0 {
|
||||
go pruneToolResults()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// loadToolResult relit un résultat enregistré.
|
||||
func loadToolResult(id string) (string, bool) {
|
||||
if strings.ContainsAny(id, "/\\") {
|
||||
return "", false
|
||||
}
|
||||
b, err := getStoreBytesErr(bkToolRes, id)
|
||||
if err != nil || b == nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// deleteToolResultsFor supprime les résultats d'une discussion supprimée.
|
||||
func deleteToolResultsFor(sid string) {
|
||||
if sid == "" {
|
||||
return
|
||||
}
|
||||
prefix := sid + "."
|
||||
_ = update(bkToolRes, func(b *bolt.Bucket) error {
|
||||
c := b.Cursor()
|
||||
var keys [][]byte
|
||||
for k, _ := c.Seek([]byte(prefix)); k != nil && strings.HasPrefix(string(k), prefix); k, _ = c.Next() {
|
||||
keys = append(keys, append([]byte(nil), k...))
|
||||
}
|
||||
for _, k := range keys {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// pruneToolResults supprime les résultats de discussions qui n'existent plus.
|
||||
func pruneToolResults() {
|
||||
// Mémoire verrouillée : l'index des discussions est illisible et reviendrait
|
||||
// vide — tout passerait pour orphelin. On attend le déverrouillage.
|
||||
if memEncActive() && !memUnlocked() {
|
||||
return
|
||||
}
|
||||
alive := map[string]bool{getStr(bkChat, ckActive): true}
|
||||
for _, m := range convIndex() {
|
||||
alive[m.ID] = true
|
||||
}
|
||||
_ = update(bkToolRes, func(b *bolt.Bucket) error {
|
||||
var orphans []string
|
||||
_ = b.ForEach(func(k, _ []byte) error {
|
||||
key := string(k)
|
||||
if i := strings.LastIndexByte(key, '.'); i > 0 && key[:i] != "nosession" && !alive[key[:i]] {
|
||||
orphans = append(orphans, key)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for _, k := range orphans {
|
||||
if err := b.Delete([]byte(k)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un résultat coupé est rangé à part, relu en entier, et part avec sa discussion.
|
||||
func TestToolResultsSaveLoadDelete(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = putStr(bkChat, ckActive, "c42")
|
||||
long := strings.Repeat("ligne de sortie\n", 500)
|
||||
ev := fillToolResult(&ToolUsedEvent{Name: "bash", Done: true}, long)
|
||||
if ev.ResultID == "" || !strings.HasPrefix(ev.ResultID, "c42.") {
|
||||
t.Fatalf("id attendu préfixé par la discussion, got %q", ev.ResultID)
|
||||
}
|
||||
if len([]rune(ev.Result)) != toolPreviewChars || ev.ResultChars != len([]rune(long)) {
|
||||
t.Fatalf("aperçu %d car., taille %d — attendu %d et %d", len([]rune(ev.Result)), ev.ResultChars, toolPreviewChars, len([]rune(long)))
|
||||
}
|
||||
if got, ok := loadToolResult(ev.ResultID); !ok || got != long {
|
||||
t.Fatal("le résultat complet doit être relisible")
|
||||
}
|
||||
short := fillToolResult(&ToolUsedEvent{}, "ok")
|
||||
if short.ResultID != "" || short.Result != "ok" {
|
||||
t.Fatal("un résultat court part entier, sans id")
|
||||
}
|
||||
deleteToolResultsFor("c42")
|
||||
if _, ok := loadToolResult(ev.ResultID); ok {
|
||||
t.Fatal("les résultats doivent partir avec leur discussion")
|
||||
}
|
||||
}
|
||||
|
||||
// Une sortie de commande coupée le DIT, avec sa taille réelle.
|
||||
func TestTailOutputAnnouncesCut(t *testing.T) {
|
||||
if tailOutput("court") != "court" {
|
||||
t.Fatal("une sortie courte reste intacte")
|
||||
}
|
||||
out := tailOutput(strings.Repeat("x", toolMaxOutput+10))
|
||||
if !strings.HasPrefix(out, "[sortie tronquée : ") || !strings.Contains(out, "au total") {
|
||||
t.Fatalf("mention de coupe absente : %q", out[:60])
|
||||
}
|
||||
}
|
||||
|
||||
// Mémoire chiffrée : le résultat complet est chiffré en base et relu intact ;
|
||||
// verrouillée, rien n'est écrit (le flux portera le résultat entier) et le
|
||||
// nettoyage des orphelins ne tourne pas (l'index illisible ferait tout effacer).
|
||||
func TestToolResultsChiffres(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
|
||||
t.Fatalf("EnableMemEncryption: %v", err)
|
||||
}
|
||||
full := strings.Repeat("ligne de sortie\n", 500)
|
||||
id := saveToolResult(full)
|
||||
if id == "" {
|
||||
t.Fatal("résultat non enregistré")
|
||||
}
|
||||
if !looksEncrypted(getBytes(bkToolRes, id)) {
|
||||
t.Fatal("résultat en clair alors que la mémoire est chiffrée")
|
||||
}
|
||||
if got, ok := loadToolResult(id); !ok || got != full {
|
||||
t.Fatal("résultat chiffré non restitué à l'identique")
|
||||
}
|
||||
clearMemDEK()
|
||||
if saveToolResult("autre") != "" {
|
||||
t.Fatal("mémoire verrouillée : rien n'aurait dû être écrit")
|
||||
}
|
||||
pruneToolResults()
|
||||
if getBytes(bkToolRes, id) == nil {
|
||||
t.Fatal("le nettoyage a effacé un résultat pendant que la mémoire était verrouillée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// orderedProps : propriétés d'un schéma d'outil sérialisées DANS L'ORDRE donné.
|
||||
//
|
||||
// Une map Go sort ses clés par ordre alphabétique : write annonçait content
|
||||
// AVANT file, edit new avant old. Le modèle écrit ses arguments dans l'ordre
|
||||
// du schéma — il déroulait donc tout le contenu d'un fichier avant d'en donner
|
||||
// le chemin. Le chemin d'abord permet de refuser en plein flux une écriture
|
||||
// interdite (fichier non lu) au lieu d'attendre la fin, et à l'interface de
|
||||
// nommer le fichier dès le début de la frappe (repris d'OpenFox, 2.0.154).
|
||||
type orderedProps []propEntry
|
||||
|
||||
type propEntry struct {
|
||||
Key string
|
||||
Schema map[string]any
|
||||
}
|
||||
|
||||
func (o orderedProps) MarshalJSON() ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('{')
|
||||
for i, p := range o {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
k, err := json.Marshal(p.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := json.Marshal(p.Schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Write(k)
|
||||
b.WriteByte(':')
|
||||
b.Write(v)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// preflightRefusal : write/edit refusé pendant le flux, dès que son chemin est
|
||||
// connu (voir runChat). msg vide = chemin vérifié et accepté.
|
||||
type preflightRefusal struct {
|
||||
name, id, file, msg string
|
||||
}
|
||||
+336
-127
@@ -478,29 +478,19 @@ 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 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}
|
||||
/* Résultat d'outil : « voir plus » + « copier » réunis dans une barre ancrée en
|
||||
bas à droite. Le pre scrolle (max-height), donc la barre est portée par le
|
||||
bloc-parent NON scrollant (.tool-block) : elle reste au coin. Déplié, le pre
|
||||
lève son plafond pour montrer VRAIMENT plus (AJEAN 0.15.4). */
|
||||
.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-actions .tool-more{margin:0;padding:3px 8px;font:inherit;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;cursor:pointer;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 .tool-actions .tool-more:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.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}
|
||||
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
|
||||
@@ -1255,6 +1245,19 @@ html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
|
||||
#attach-list::-webkit-scrollbar{display:none}
|
||||
#attach-list.show{display:flex}
|
||||
#composer:has(#attach-list.show){--fade:6px}
|
||||
/* File d'attente : messages envoyés pendant une réponse, pas encore pris en
|
||||
compte. Même bande que les pièces jointes, en gris. */
|
||||
#queue-list{order:0;display:none;flex-direction:column;align-items:flex-end;gap:4px;margin:0 var(--col);padding:0 2px 8px}
|
||||
#queue-list.show{display:flex}
|
||||
#composer:has(#queue-list.show){--fade:6px}
|
||||
#queue-list .queued-msg{max-width:80%;padding:5px 12px;border-radius:12px;background:var(--panel);
|
||||
border:1px dashed var(--border);color:var(--dim);font-size:12px;line-height:1.35;
|
||||
white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
|
||||
box-shadow:0 2px 10px rgba(0,0,0,.10)}
|
||||
#queue-list .queued-msg::before{content:"en attente · ";font-style:italic;opacity:.8}
|
||||
/* Pendant une réponse, envoyer reste possible dès qu'il y a du texte : le
|
||||
message part en file (voir send()). Stop reste à côté. */
|
||||
html[data-busy="1"][data-hastext="1"] #send{display:inline-flex}
|
||||
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
|
||||
cadre, sinon le texte de la conversation transparaît derrière. */
|
||||
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
|
||||
@@ -1746,6 +1749,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
color:var(--dim);letter-spacing:-.01em;overflow:visible}
|
||||
.side .preset:hover .conv-when{opacity:0}
|
||||
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
|
||||
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
|
||||
border:0;padding:8px 11px;text-align:left;cursor:pointer}
|
||||
.conv-more:hover{color:var(--text)}
|
||||
/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
|
||||
élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
|
||||
agent est en train de travailler, y compris depuis un autre appareil. */
|
||||
@@ -2137,6 +2143,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#criteria-list li.crit-passed{color:var(--accent)}
|
||||
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
|
||||
#criteria-list li.crit-pending{color:var(--dim)}
|
||||
#criteria-list li.crit-completed{color:var(--text)}
|
||||
|
||||
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
||||
|
||||
@@ -2242,6 +2249,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
.md-report table{border-collapse:collapse;margin:.5em 0}
|
||||
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
|
||||
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
|
||||
|
||||
/* Historique paginé : bouton « échanges précédents » en tête du fil. */
|
||||
#chat .history-more{display:flex;justify-content:center;padding:6px 0 14px}
|
||||
#chat .history-more button{font:inherit;font-size:12px;color:var(--dim);background:var(--row-bg);
|
||||
border:1px solid var(--border);border-radius:999px;padding:6px 14px;cursor:pointer;
|
||||
transition:background .15s,color .15s,border-color .15s}
|
||||
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
||||
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
||||
</style></head><body>
|
||||
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
|
||||
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
||||
@@ -2371,6 +2386,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
||||
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
||||
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
||||
<!-- Messages envoyés PENDANT une réponse (file d'attente, voir send()) : en
|
||||
gris au-dessus de la carte jusqu'à ce que le serveur les prenne en compte
|
||||
— injectés dans la réponse en cours, ou traités au tour suivant. -->
|
||||
<div id="queue-list" aria-live="polite"></div>
|
||||
<div id="attach-list"></div>
|
||||
<div id="inputbar">
|
||||
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
||||
@@ -2484,6 +2503,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
||||
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
||||
</div>
|
||||
<div class="pe-row plain">
|
||||
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
||||
<div id="ext-test-out" class="pe-note"></div>
|
||||
@@ -2584,9 +2606,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<div id="mem-block" class="gated-group">
|
||||
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span>mode</span>
|
||||
<label class="switchrow"><span>mode <small class="dim">(projet actif)</small></span>
|
||||
<select id="mem-mode" onchange="setMemMode()" class="sctl">
|
||||
<option value="always">auto</option>
|
||||
<option value="always">auto, injectée</option>
|
||||
<option value="search">auto, recherche</option>
|
||||
<option value="ondemand">sur demande</option>
|
||||
<option value="off">désactivée</option>
|
||||
</select>
|
||||
@@ -3439,7 +3462,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<option value="">f16 (max qualité)</option>
|
||||
<option value="q8_0">q8_0 (léger)</option>
|
||||
<option value="q4_0">q4_0 (très léger)</option>
|
||||
<option value="q5_1">q5_1</option>
|
||||
<option value="q5_1">q5_1 (lent sur CUDA)</option>
|
||||
<option value="bf16">bf16</option>
|
||||
</select></span></span>
|
||||
</div>
|
||||
@@ -3492,13 +3515,19 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<!-- Chargement du modèle : --load-mode de llama.cpp (remplace --mlock et
|
||||
--no-mmap). Pour un moteur ancien, loki serve retraduit au lancement
|
||||
(normalizeLoadFlags). -->
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
|
||||
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
|
||||
<option value="">auto</option>
|
||||
<option value="mmap">mmap</option>
|
||||
<option value="none">none</option>
|
||||
<option value="mlock">mlock</option>
|
||||
<option value="mmap+mlock">mmap+mlock</option>
|
||||
<option value="dio">dio</option>
|
||||
</select></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3914,8 +3943,17 @@ function askKeyOnce(){
|
||||
}
|
||||
async function jget(u){ const r=await jfetch(u); return r.json(); }
|
||||
async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); }
|
||||
let LAST_PRESET=null; // id du preset actif vu au dernier poll (détecte une bascule faite ailleurs)
|
||||
async function loadStatus(){
|
||||
const s=await jget('/api/status');
|
||||
// Preset actif changé alors qu'on n'a rien touché ici : un AUTRE appareil a
|
||||
// basculé de preset/modèle. On rafraîchit la liste (et le libellé du preset)
|
||||
// pour rester en phase sans recharger l'app. Premier poll (LAST_PRESET null) :
|
||||
// on note seulement la valeur, loadPresets a déjà tourné au démarrage.
|
||||
if(typeof s.preset!=='undefined'){
|
||||
if(LAST_PRESET!==null && s.preset!==LAST_PRESET && typeof loadPresets==='function') loadPresets();
|
||||
LAST_PRESET=s.preset;
|
||||
}
|
||||
const el=document.getElementById('status-svc');
|
||||
// Trois états : service coupé (err) · service actif mais modèle pas encore
|
||||
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
|
||||
@@ -4288,7 +4326,7 @@ async function loadPresets(){
|
||||
const row=document.createElement('div');
|
||||
const pend = !x.active && pendingPreset===i+1;
|
||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
||||
row.onclick=()=>switchTo(i+1, x.name);
|
||||
row.onclick=()=>switchTo(i+1, x.name, x.id);
|
||||
const info=document.createElement('div'); info.className='preset-info';
|
||||
const nm=document.createElement('div'); nm.className='preset-name';
|
||||
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
||||
@@ -4542,9 +4580,10 @@ async function toggleCompact(){
|
||||
const on=document.getElementById('compact-toggle').checked;
|
||||
await jpost('/api/agent/compact',{on});
|
||||
}
|
||||
// Mode mémoire (3 états) — indépendant du mode agent.
|
||||
// Mode mémoire (4 états), réglé PAR PROJET — chaque projet garde le sien.
|
||||
const MEM_DESC={
|
||||
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
|
||||
always:'L\'index des pages est placé en tête de conversation : l\'IA voit d\'emblée ce qu\'elle sait, lit directement la bonne page et sauve d\'elle-même ce qui mérite d\'être retenu. Réglage propre au projet actif.',
|
||||
search:'Rien n\'est injecté : l\'IA cherche dans sa mémoire avant chaque tâche. Contexte plus léger, démarrage plus rapide. Réglage propre au projet actif.',
|
||||
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
|
||||
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
|
||||
};
|
||||
@@ -4893,14 +4932,15 @@ async function runBenchUI(){
|
||||
loadPresets();
|
||||
}
|
||||
}
|
||||
async function switchTo(n,name){
|
||||
async function switchTo(n,name,id){
|
||||
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
|
||||
toast('switching…');
|
||||
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
|
||||
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
|
||||
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
|
||||
pendingPreset = n; loadPresets();
|
||||
const r=await jpost('/api/switch',{n:n});
|
||||
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
|
||||
const r=await jpost('/api/switch',id?{id:id}:{n:n});
|
||||
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
|
||||
toast('switched');
|
||||
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
|
||||
@@ -5778,8 +5818,47 @@ function populateSettings(){
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mlock', eaHasFlag('--mlock'));
|
||||
chk('s-nommap', eaHasFlag('--no-mmap'));
|
||||
const lm = eaLoadMode();
|
||||
set('s-loadmode', lm);
|
||||
syncLoadModeSub(lm);
|
||||
}
|
||||
// Chargement du modèle. llama.cpp récent a REMPLACÉ --mlock / --no-mmap par
|
||||
// --load-mode (auto, none, mmap, mlock, mmap+mlock, dio). L'éditeur écrit la
|
||||
// forme moderne ; au lancement, loki serve la retraduit pour un moteur ancien
|
||||
// (normalizeLoadFlags côté Go), et inversement.
|
||||
function eaLoadMode(){
|
||||
const lm = (eaGetValued('--load-mode') || eaGetValued('-lm')).toLowerCase();
|
||||
if(lm) return lm === 'auto' ? '' : lm;
|
||||
// Preset écrit avec les anciens drapeaux : même correspondance que le serveur.
|
||||
const mlock = eaHasFlag('--mlock'), nommap = eaHasFlag('--no-mmap');
|
||||
if(mlock && nommap) return 'mlock';
|
||||
if(mlock) return 'mmap+mlock';
|
||||
if(nommap) return 'none';
|
||||
if(eaHasFlag('--mmap')) return 'mmap';
|
||||
return '';
|
||||
}
|
||||
function eaSetLoadMode(mode){
|
||||
const t = eaTokens().filter(x=>x!=='--mlock' && x!=='--no-mmap' && x!=='--mmap' && !x.startsWith('--load-mode='));
|
||||
for(const f of ['--load-mode','-lm']){
|
||||
const i = t.indexOf(f);
|
||||
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
|
||||
}
|
||||
if(mode) t.push('--load-mode', mode);
|
||||
eaSetTokens(t);
|
||||
syncLoadModeSub(mode);
|
||||
}
|
||||
// Sous-titre : ce que fait le mode choisi (les options ne portent que son nom).
|
||||
const LOAD_MODE_SUB = {
|
||||
'': 'mmap, sauf si un périphérique ne le permet pas',
|
||||
'mmap': 'fichier mappé, lu à la demande',
|
||||
'none': 'tout charger, sans mmap',
|
||||
'mlock': 'tout en RAM, verrouillé (sans mmap)',
|
||||
'mmap+mlock': 'mappé et verrouillé en RAM',
|
||||
'dio': 'lecture DirectIO si disponible',
|
||||
};
|
||||
function syncLoadModeSub(mode){
|
||||
const el = document.getElementById('s-loadmode-sub');
|
||||
if(el) el.textContent = LOAD_MODE_SUB[mode] ?? mode;
|
||||
}
|
||||
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
|
||||
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
|
||||
@@ -6350,15 +6429,32 @@ addEventListener('DOMContentLoaded', ()=>{
|
||||
if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c);
|
||||
});
|
||||
|
||||
// iOS ANNULE un appui si la position de défilement change pendant qu'il a lieu.
|
||||
// Le fil se recalant en bas à chaque token pendant la génération, presque chaque
|
||||
// tap tombait dessus : plus rien ne répondait (pas même stop) jusqu'à la fin de
|
||||
// la réponse. On suspend donc le suivi le temps du contact, et on rattrape juste
|
||||
// après (TOUCH_GRACE). Repris d'AJEAN 0.15.7.
|
||||
let TOUCHING = false, _touchEnd = 0;
|
||||
const TOUCH_GRACE = 350;
|
||||
(function(){
|
||||
const on = ()=>{ TOUCHING = true; };
|
||||
const off = ()=>{ TOUCHING = false; _touchEnd = performance.now(); if(stickyBottom) setTimeout(()=>scrollMaybe(), TOUCH_GRACE + 20); };
|
||||
document.addEventListener('touchstart', on, {passive:true, capture:true});
|
||||
document.addEventListener('touchend', off, {passive:true, capture:true});
|
||||
document.addEventListener('touchcancel', off, {passive:true, capture:true});
|
||||
})();
|
||||
function scrollMaybe(){
|
||||
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
|
||||
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
|
||||
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
|
||||
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
|
||||
if(REPLAYING) return;
|
||||
if(stickyBottom){
|
||||
if(stickyBottom && !TOUCHING && performance.now() - _touchEnd >= TOUCH_GRACE){
|
||||
const c = chatEl();
|
||||
c.scrollTop = c.scrollHeight;
|
||||
const target = c.scrollHeight - c.clientHeight;
|
||||
// N'écrire que si l'on n'y est pas déjà : sinon on relance pour rien la
|
||||
// machinerie de défilement d'iOS, qui continue de voler les taps.
|
||||
if(Math.abs(c.scrollTop - target) > 1) c.scrollTop = c.scrollHeight;
|
||||
}
|
||||
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
|
||||
}
|
||||
@@ -6560,6 +6656,9 @@ function hydrateImages(root){
|
||||
}
|
||||
});
|
||||
}
|
||||
// Lignes d'un contenu en cours d'écriture, comptées comme côté serveur : un saut
|
||||
// de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
|
||||
function bodyLineCount(s){ s=String(s).replace(/\r\n/g,'\n').replace(/\n$/,''); return s ? s.split('\n').length : 0; }
|
||||
// Render a tool call as its own conversation message: the command the model
|
||||
// wrote, then the response it got back. textContent keeps it injection-safe.
|
||||
function renderToolMsg(el, tu){
|
||||
@@ -6601,16 +6700,18 @@ 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).
|
||||
// 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'; }
|
||||
// Taille RÉELLE (result_chars) : le flux ne porte plus qu'un aperçu.
|
||||
if(tu.result){ const n = tu.result_chars || tu.result.length; lbl += ' · ~' + Math.max(1, Math.round(n/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.
|
||||
// Les vrais totaux viennent du serveur (added/removed) : le diff lui-même est
|
||||
// tronqué pour l'affichage, le recompter donnait « +120 » pour 500 lignes.
|
||||
// Repli sur le décompte du diff pour les conversations d'avant ces champs.
|
||||
let add=0, del=0;
|
||||
if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
|
||||
else if(tu.body){ add=tu.body.split('\n').length; }
|
||||
if(tu.added!=null || tu.removed!=null){ add=tu.added||0; del=tu.removed||0; }
|
||||
else if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
|
||||
else if(tu.body){ add=bodyLineCount(tu.body); }
|
||||
if(add||del) setLabelCounts(el, add, del);
|
||||
const body=bodyOf(el); body.innerHTML='';
|
||||
const head=document.createElement('div'); head.className='tool-head';
|
||||
@@ -6684,12 +6785,9 @@ function renderToolMsg(el, tu){
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('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).
|
||||
const preview=tu.result, rid=tu.result_id;
|
||||
// result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
|
||||
// « voir plus » CHARGE le reste à la demande (AJEAN 0.15.1 / 0.15.4).
|
||||
if(rid){
|
||||
code.textContent=preview+'…';
|
||||
pre.appendChild(code);
|
||||
@@ -6698,29 +6796,19 @@ function renderToolMsg(el, tu){
|
||||
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;
|
||||
// Un échec n'est PAS mémorisé : on prévient et on laisse réessayer.
|
||||
more.disabled=true; const prevTxt=more.textContent; more.textContent='…';
|
||||
let got=null;
|
||||
try{ const r=await jfetch('/api/chat/tool-result?id='+encodeURIComponent(rid)); const j=await r.json(); if(r.ok && j && typeof j.result==='string') got=j.result; }
|
||||
catch(_){}
|
||||
more.disabled=false; more.textContent=prevTxt;
|
||||
if(got===null){ toast('résultat complet indisponible'); return; }
|
||||
full=got;
|
||||
}
|
||||
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';
|
||||
more.textContent = open ? 'réduire' : 'voir plus';
|
||||
};
|
||||
body.appendChild(toolBlock(pre, more));
|
||||
} else {
|
||||
@@ -6739,7 +6827,7 @@ function renderToolMsg(el, tu){
|
||||
}
|
||||
// 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
|
||||
// reste au coin même quand on scrolle dans le résultat. Elle 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';
|
||||
@@ -6752,9 +6840,8 @@ function toolBlock(pre, more){
|
||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
root.querySelectorAll('pre').forEach(pre=>{
|
||||
// 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.
|
||||
// Résultat d'outil : la barre d'actions est SŒUR du pre (pas dedans) ; on
|
||||
// cherche donc le copier dans le bloc qui les réunit.
|
||||
const scope = pre.closest('.tool-block') || pre;
|
||||
if(scope.querySelector('.copybtn')) return;
|
||||
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
|
||||
@@ -6769,8 +6856,8 @@ function addCopyButtons(root){
|
||||
btn.textContent='copié ✓'; btn.classList.add('done');
|
||||
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
|
||||
};
|
||||
// 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.
|
||||
// Barre d'actions (résultat d'outil avec « voir plus ») : le copier s'y
|
||||
// range à côté ; sinon il se colle en bas à droite du bloc.
|
||||
(scope.querySelector('.tool-actions')||pre).appendChild(btn);
|
||||
});
|
||||
}
|
||||
@@ -6879,6 +6966,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(
|
||||
function autoGrow(ta){
|
||||
ta = ta || document.getElementById('input');
|
||||
if(!ta) return;
|
||||
// Du texte à envoyer ? Pendant une réponse, c'est ce qui fait réapparaître
|
||||
// « envoyer » à côté de stop (message mis en file, voir send()).
|
||||
if(ta.id==='input') document.documentElement.setAttribute('data-hastext', ta.value.trim() ? '1' : '0');
|
||||
ta.style.height='auto';
|
||||
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
|
||||
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
|
||||
@@ -6890,10 +6980,39 @@ function autoGrow(ta){
|
||||
// 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;
|
||||
// Historique paginé (AJEAN 0.15.7) : au chargement, le serveur ne rejoue que les
|
||||
// HIST_TAIL derniers échanges et annonce le reste ({history_more: N}). Le bouton
|
||||
// en haut du fil redemande alors un rejeu complet (HIST_FULL) en gardant la
|
||||
// position de lecture (HIST_RESTORE = distance au bas du fil, réappliquée au
|
||||
// caught_up).
|
||||
const HIST_TAIL=20;
|
||||
let HIST_FULL=false, HIST_RESTORE=null;
|
||||
// Discussion AFFICHÉE (id reçu au caught_up/reset). Renvoyée à chaque
|
||||
// (ré)abonnement : si un autre appareil a changé de discussion pendant une
|
||||
// coupure, le serveur ordonne un reset au lieu de greffer le nouveau fil sur
|
||||
// l'ancien (AJEAN 0.14.0).
|
||||
let CONV_ID='';
|
||||
// File d'attente (AJEAN 0.14.0) : messages envoyés PENDANT une réponse, montrés
|
||||
// en gris au-dessus de la carte jusqu'à ce que le flux les confirme (delta
|
||||
// `user` du même texte) — injectés en cours de réponse ou au tour suivant.
|
||||
function queueAdd(text){
|
||||
const box=document.getElementById('queue-list'); if(!box) return null;
|
||||
const el=document.createElement('div'); el.className='queued-msg'; el.textContent=text||'(pièce jointe)';
|
||||
el.dataset.text=text;
|
||||
box.appendChild(el); box.classList.add('show');
|
||||
return el;
|
||||
}
|
||||
function queueSync(){ const box=document.getElementById('queue-list'); if(box) box.classList.toggle('show', !!box.children.length); }
|
||||
function queueTake(text){
|
||||
const box=document.getElementById('queue-list'); if(!box) return;
|
||||
const el=[...box.children].find(e=>e.dataset.text===text);
|
||||
if(el) el.remove();
|
||||
queueSync();
|
||||
}
|
||||
function queueClear(){ const box=document.getElementById('queue-list'); if(box) box.textContent=''; queueSync(); }
|
||||
// Identifiant d'envoi, stable d'un réessai à l'autre : le serveur ne met pas
|
||||
// deux fois le même message en file si la réponse s'est perdue en route.
|
||||
function newCID(){ try{ return crypto.randomUUID(); }catch(_){ return Date.now().toString(36)+Math.random().toString(36).slice(2); } }
|
||||
// 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
|
||||
@@ -7094,7 +7213,8 @@ function renderStats(el, s){
|
||||
if(!el||!s) return;
|
||||
const parts=[];
|
||||
const pt=s.prompt_tokens||s.prompt_tokens_total;
|
||||
if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
|
||||
// Débit de lecture inconnu (API tierce sans timings) : on n'affiche que la taille.
|
||||
if(pt) parts.push('prefill '+pt+' tok'+(s.prompt_per_second ? ' · '+s.prompt_per_second.toFixed(0)+' tok/s' : ''));
|
||||
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
|
||||
if(!parts.length) return;
|
||||
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
|
||||
@@ -7293,54 +7413,38 @@ 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();
|
||||
// Rafraîchissement groupé de la liste des discussions (plusieurs événements
|
||||
// rapprochés = un seul appel).
|
||||
let histRefreshTimer=null;
|
||||
function histRefreshSoon(){
|
||||
if(typeof loadConversations!=='function') return;
|
||||
clearTimeout(histRefreshTimer);
|
||||
histRefreshTimer=setTimeout(()=>{ histRefreshTimer=null; loadConversations(); }, 400);
|
||||
}
|
||||
|
||||
// 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.
|
||||
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
|
||||
if(d.caught_up){
|
||||
if(typeof d.id==='string') CONV_ID=d.id;
|
||||
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
|
||||
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
|
||||
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
|
||||
setChatLoading(null);
|
||||
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
|
||||
if(REPLAYING && HIST_RESTORE!==null){
|
||||
// Historique complet demandé : on garde la position de lecture (distance au
|
||||
// bas du fil) au lieu de sauter en bas, et on la recale une fois la mise en
|
||||
// page stabilisée (le rejeu pose ses blocs de façon différée).
|
||||
REPLAYING=false; syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1';
|
||||
const keep=HIST_RESTORE; HIST_RESTORE=null; stickyBottom=false;
|
||||
const put=()=>{ c.scrollTop=Math.max(0, c.scrollHeight-keep); };
|
||||
put(); requestAnimationFrame(()=>requestAnimationFrame(put)); setTimeout(put, 80); setTimeout(put, 250);
|
||||
} else if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
|
||||
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
|
||||
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
|
||||
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
|
||||
@@ -7353,7 +7457,9 @@ function handleDelta(d){
|
||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||||
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
||||
if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; }
|
||||
if(d.queue_dropped!==undefined){ if(!REPLAYING){ queueClear(); toast('messages en attente abandonnés'); } return; }
|
||||
if(d.reset!==undefined){ if(typeof d.id==='string') CONV_ID=d.id; queueClear(); HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
||||
// --- Mode code (20-mode.js) -----------------------------------------------
|
||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
||||
@@ -7370,6 +7476,10 @@ function handleDelta(d){
|
||||
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
|
||||
// bulle de réponse du tour.
|
||||
T.model = d.model || '';
|
||||
if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte
|
||||
// Le serveur a déjà enregistré la discussion (StartTurn persiste) : la liste
|
||||
// la montre dès le premier message, sans attendre la fin de la réponse.
|
||||
if(!REPLAYING) histRefreshSoon();
|
||||
let el=PENDING;
|
||||
if(!confirmPending(d.user)) el=addMsg('user', d.user);
|
||||
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
|
||||
@@ -7438,6 +7548,17 @@ function handleDelta(d){
|
||||
return; }
|
||||
if(d.reasoning_content){
|
||||
killTyping('reasoning');
|
||||
// Raisonnement qui reprend APRÈS du texte de réponse (modèle qui repense en
|
||||
// cours de réponse, reprise après coupure) : il allait dans l'ANCIENNE bulle,
|
||||
// déjà repliée au-dessus de la réponse. On en ouvre une nouvelle sous la
|
||||
// réponse : l'ordre affiché suit l'ordre reçu (AJEAN 0.17.4).
|
||||
// Réponse encore vide (simple saut de ligne avant la réflexion) : on retire
|
||||
// la bulle vide et le raisonnement continue dans la sienne.
|
||||
if(T.contentEl){
|
||||
if((T.fullContent||'').trim()){ settleBlocks(); T.reasonEl=null; }
|
||||
else { smoothReset(); cancelRender(); T.contentEl.remove(); }
|
||||
T.contentEl=null;
|
||||
}
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
||||
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
||||
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
||||
@@ -7481,7 +7602,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, full:REPLAY_FULL}),signal:streamAbort.signal});
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL,conv_id:CONV_ID}),signal:streamAbort.signal});
|
||||
if(REPLAYING) setChatLoading('chargement de la conversation…');
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
@@ -7506,24 +7627,71 @@ async function connectStream(){
|
||||
}
|
||||
// Interrompt la génération en cours côté serveur (la goroutine détachée est
|
||||
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
|
||||
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
|
||||
// _stopAt : anti-doublon, le bouton réagit à l'APPUI sur écran tactile (voir plus
|
||||
// bas) puis le clic qui suit éventuellement ne doit pas relancer.
|
||||
let _stopAt = 0;
|
||||
function stopGen(){
|
||||
const now = Date.now(); if(now - _stopAt < 800) return; _stopAt = now;
|
||||
jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop');
|
||||
}
|
||||
// Sur écran tactile, stop agit dès l'appui (pointerdown) : le « clic » complet
|
||||
// peut être annulé par iOS si le fil défile au même moment — or c'est justement
|
||||
// quand l'IA écrit qu'on veut l'arrêter vite. Repris d'AJEAN 0.15.7.
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const st=document.getElementById('stop'); if(!st) return;
|
||||
st.addEventListener('pointerdown', (e)=>{ if(e.pointerType!=='mouse'){ e.preventDefault(); stopGen(); } });
|
||||
});
|
||||
// Bouton « échanges précédents » posé en tête du fil quand le serveur n'a rejoué
|
||||
// que la fin de la conversation.
|
||||
function showHistoryMore(n){
|
||||
const chat=chatEl(); if(!chat || !n) return;
|
||||
let box=chat.querySelector('.history-more');
|
||||
if(!box){ box=document.createElement('div'); box.className='history-more'; chat.insertBefore(box, chat.firstChild); }
|
||||
box.innerHTML='';
|
||||
const b=document.createElement('button'); b.type='button';
|
||||
b.textContent = n===1 ? 'afficher l\'échange précédent' : 'afficher les '+n+' échanges précédents';
|
||||
b.onclick=loadFullHistory;
|
||||
box.appendChild(b);
|
||||
}
|
||||
// Rejeu COMPLET de la conversation vive, en gardant ce qu'on lisait à l'écran :
|
||||
// on vide le fil, on coupe le flux, et la boucle connectStream se reconnecte
|
||||
// depuis le début sans pagination (tail=0).
|
||||
function loadFullHistory(){
|
||||
const chat=chatEl(); if(!chat) return;
|
||||
const box=chat.querySelector('.history-more'); if(box){ const b=box.querySelector('button'); if(b){ b.disabled=true; b.textContent='chargement…'; } }
|
||||
HIST_RESTORE=chat.scrollHeight-chat.scrollTop;
|
||||
HIST_FULL=true;
|
||||
chat.innerHTML=''; newTurn();
|
||||
smoothReset(); cancelRender();
|
||||
lastSeq=0; REPLAYING=true;
|
||||
if(streamAbort){ try{ streamAbort.abort(); }catch(e){} }
|
||||
}
|
||||
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
|
||||
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
|
||||
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
|
||||
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
|
||||
// « network error » alarmiste alors que l'IA répond quand même.
|
||||
// Fuseau du navigateur, envoyé avec chaque message : le conteneur tourne en UTC,
|
||||
// et « rappelle-moi à 17h » doit tomber à 17h ici (voir rememberUserTZ).
|
||||
const USER_TZ=(()=>{ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||''; }catch(_){ return ''; } })();
|
||||
async function send(){
|
||||
if(busy) return;
|
||||
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
|
||||
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
|
||||
const ta=document.getElementById('input'); const text=ta.value.trim();
|
||||
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
|
||||
if(!text && !ATTACH.length) return;
|
||||
ta.value=''; autoGrow(ta);
|
||||
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
|
||||
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
|
||||
addPending(text);
|
||||
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
|
||||
// Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en
|
||||
// compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche
|
||||
// en attente au-dessus de la carte, pas dans le fil qui s'écrit encore.
|
||||
const queuedAtSend = busy;
|
||||
let qel=null;
|
||||
// Sinon le message s'affiche TOUT DE SUITE dans le fil, en gris : il ne
|
||||
// disparaît plus le temps de l'aller-retour. Il s'éclaircit quand le flux le
|
||||
// confirme (confirmPending).
|
||||
if(queuedAtSend) qel=queueAdd(text); else addPending(text);
|
||||
const fail=(m)=>{ if(qel){ qel.remove(); queueSync(); } else clearPending(); toast(m); ta.value=text; autoGrow(ta); };
|
||||
const cid=newCID();
|
||||
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
|
||||
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
|
||||
// on doit pouvoir en enlever une, et un échec doit rester visible.
|
||||
@@ -7531,13 +7699,20 @@ async function send(){
|
||||
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
|
||||
// Les pastilles passent dans la bulle en attente : le message porte ses
|
||||
// fichiers dès l'envoi, sans attendre l'aller-retour.
|
||||
if(PENDING) addMsgFiles(PENDING, attachSent());
|
||||
if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent());
|
||||
for(let attempt=0; attempt<3; attempt++){
|
||||
try{
|
||||
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
|
||||
if(r.status===409 || r.ok) clearAttach();
|
||||
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
|
||||
if(r.ok) return; // la bulle + les tokens arrivent par le flux
|
||||
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid,tz:USER_TZ})});
|
||||
if(r.ok) clearAttach();
|
||||
// 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
|
||||
if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
|
||||
if(r.ok){
|
||||
// Parti tout de suite alors qu'on le croyait en file (le tour venait de
|
||||
// finir) : la pastille disparaît, la bulle arrive par le flux.
|
||||
let j={}; try{ j=await r.json(); }catch(_){}
|
||||
if(qel && j.queued===false){ qel.remove(); queueSync(); }
|
||||
return; // la bulle + les tokens arrivent par le flux
|
||||
}
|
||||
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
|
||||
}catch(e){ /* réseau : on retente */ }
|
||||
await new Promise(res=>setTimeout(res, 600));
|
||||
@@ -7963,6 +8138,7 @@ async function openExternal(id){
|
||||
document.getElementById('ext-test-out').textContent = '';
|
||||
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
||||
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
||||
document.getElementById('ext-vision').checked = false;
|
||||
showModal('ext-modal');
|
||||
if(!id) return;
|
||||
let d = {};
|
||||
@@ -7970,6 +8146,7 @@ async function openExternal(id){
|
||||
if(extEditing !== id) return; // une autre ouverture a pris la main
|
||||
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
||||
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
||||
document.getElementById('ext-vision').checked = !!d.vision;
|
||||
if(d.hasKey){
|
||||
document.getElementById('ext-key-sub').textContent =
|
||||
'une clé est enregistrée — laisse vide pour la conserver';
|
||||
@@ -7986,6 +8163,7 @@ function extPayload(){
|
||||
key: document.getElementById('ext-key').value || '',
|
||||
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
||||
keyTouched: extKeyTouched,
|
||||
vision: document.getElementById('ext-vision').checked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8015,9 +8193,11 @@ async function saveExternal(){
|
||||
if(!p.model){ toast('modèle requis'); return; }
|
||||
const r = await jpost('/api/preset/external/save', p);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
toast('enregistré');
|
||||
// Preset en service : le serveur l'a appliqué aussitôt (applied).
|
||||
toast(r.applied ? 'enregistré et appliqué' : 'enregistré');
|
||||
closeExternal();
|
||||
loadPresets();
|
||||
if(r.applied && typeof loadStatus === 'function') loadStatus();
|
||||
}
|
||||
|
||||
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
||||
@@ -9064,6 +9244,12 @@ let CONVS = [], CONV_ACTIVE = '';
|
||||
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
|
||||
// route, ou ouverte depuis un autre appareil).
|
||||
let CONV_BUSY = false;
|
||||
// Liste paginée (AJEAN 0.15.6) : avec des centaines de discussions, construire
|
||||
// toutes les lignes d'un coup figeait l'ouverture du panneau. On en dessine
|
||||
// CONV_PAGE, puis la suite quand le bas de la liste devient visible. La
|
||||
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
|
||||
const CONV_PAGE = 40;
|
||||
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
|
||||
|
||||
async function loadConversations(){
|
||||
let r;
|
||||
@@ -9086,6 +9272,7 @@ function renderConversations(){
|
||||
if(!cont) return;
|
||||
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
|
||||
cont.textContent = '';
|
||||
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
|
||||
const list = q
|
||||
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
|
||||
: CONVS;
|
||||
@@ -9098,7 +9285,10 @@ function renderConversations(){
|
||||
convSyncBusy();
|
||||
return;
|
||||
}
|
||||
for(const c of list){
|
||||
// La discussion active reste toujours visible, même loin dans la liste.
|
||||
const ai = list.findIndex(c => c.id === CONV_ACTIVE);
|
||||
const shown = Math.max(CONV_SHOWN, ai + 1);
|
||||
for(const c of list.slice(0, shown)){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
|
||||
@@ -9136,10 +9326,28 @@ function renderConversations(){
|
||||
row.append(info, when, acts);
|
||||
cont.appendChild(row);
|
||||
}
|
||||
if(list.length > shown) convMoreRow(cont, list.length - shown);
|
||||
syncTopbarTitle();
|
||||
convSyncBusy();
|
||||
}
|
||||
|
||||
// Ligne « N de plus » en bas de la liste : un clic, ou son arrivée à l'écran
|
||||
// (IntersectionObserver), dessine la page suivante.
|
||||
function convMoreRow(cont, left){
|
||||
const more = document.createElement('button');
|
||||
more.type = 'button';
|
||||
more.className = 'conv-more';
|
||||
more.textContent = 'afficher ' + Math.min(left, CONV_PAGE) + ' de plus (' + left + ' restantes)';
|
||||
const next = () => { CONV_SHOWN += CONV_PAGE; renderConversations(); };
|
||||
more.onclick = next;
|
||||
cont.appendChild(more);
|
||||
if(convMoreObs) convMoreObs.disconnect();
|
||||
if(window.IntersectionObserver){
|
||||
convMoreObs = new IntersectionObserver(es => { if(es.some(e => e.isIntersecting)){ convMoreObs.disconnect(); next(); } });
|
||||
convMoreObs.observe(more);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Discussion au travail : anneau qui tourne ------------------------------
|
||||
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
|
||||
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
|
||||
@@ -9412,7 +9620,7 @@ function onModelSwitch(sel){
|
||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
|
||||
// pendant les quelques secondes du redémarrage.
|
||||
switchTo(n, name);
|
||||
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
|
||||
}
|
||||
|
||||
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||
@@ -9490,6 +9698,7 @@ function paintModelSwitch(){
|
||||
MS_PRESETS.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(i + 1);
|
||||
o.dataset.id = p.id || '';
|
||||
o.textContent = p.name;
|
||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||
g.appendChild(o);
|
||||
@@ -9686,7 +9895,7 @@ function renderCriteria(list){
|
||||
for(const c of list){
|
||||
const li = document.createElement('li');
|
||||
li.className = 'crit-'+(c.status||'pending');
|
||||
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
|
||||
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : (c.status==='completed' ? '◐' : '○'));
|
||||
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
@@ -163,6 +163,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
||||
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
||||
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
||||
<!-- Messages envoyés PENDANT une réponse (file d'attente, voir send()) : en
|
||||
gris au-dessus de la carte jusqu'à ce que le serveur les prenne en compte
|
||||
— injectés dans la réponse en cours, ou traités au tour suivant. -->
|
||||
<div id="queue-list" aria-live="polite"></div>
|
||||
<div id="attach-list"></div>
|
||||
<div id="inputbar">
|
||||
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
||||
@@ -276,6 +280,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
||||
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
||||
</div>
|
||||
<div class="pe-row plain">
|
||||
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
||||
<div id="ext-test-out" class="pe-note"></div>
|
||||
@@ -376,9 +383,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div id="mem-block" class="gated-group">
|
||||
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span>mode</span>
|
||||
<label class="switchrow"><span>mode <small class="dim">(projet actif)</small></span>
|
||||
<select id="mem-mode" onchange="setMemMode()" class="sctl">
|
||||
<option value="always">auto</option>
|
||||
<option value="always">auto, injectée</option>
|
||||
<option value="search">auto, recherche</option>
|
||||
<option value="ondemand">sur demande</option>
|
||||
<option value="off">désactivée</option>
|
||||
</select>
|
||||
@@ -1231,7 +1239,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<option value="">f16 (max qualité)</option>
|
||||
<option value="q8_0">q8_0 (léger)</option>
|
||||
<option value="q4_0">q4_0 (très léger)</option>
|
||||
<option value="q5_1">q5_1</option>
|
||||
<option value="q5_1">q5_1 (lent sur CUDA)</option>
|
||||
<option value="bf16">bf16</option>
|
||||
</select></span></span>
|
||||
</div>
|
||||
@@ -1284,13 +1292,19 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<!-- Chargement du modèle : --load-mode de llama.cpp (remplace --mlock et
|
||||
--no-mmap). Pour un moteur ancien, loki serve retraduit au lancement
|
||||
(normalizeLoadFlags). -->
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
|
||||
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
|
||||
<option value="">auto</option>
|
||||
<option value="mmap">mmap</option>
|
||||
<option value="none">none</option>
|
||||
<option value="mlock">mlock</option>
|
||||
<option value="mmap+mlock">mmap+mlock</option>
|
||||
<option value="dio">dio</option>
|
||||
</select></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
let LAST_PRESET=null; // id du preset actif vu au dernier poll (détecte une bascule faite ailleurs)
|
||||
async function loadStatus(){
|
||||
const s=await jget('/api/status');
|
||||
// Preset actif changé alors qu'on n'a rien touché ici : un AUTRE appareil a
|
||||
// basculé de preset/modèle. On rafraîchit la liste (et le libellé du preset)
|
||||
// pour rester en phase sans recharger l'app. Premier poll (LAST_PRESET null) :
|
||||
// on note seulement la valeur, loadPresets a déjà tourné au démarrage.
|
||||
if(typeof s.preset!=='undefined'){
|
||||
if(LAST_PRESET!==null && s.preset!==LAST_PRESET && typeof loadPresets==='function') loadPresets();
|
||||
LAST_PRESET=s.preset;
|
||||
}
|
||||
const el=document.getElementById('status-svc');
|
||||
// Trois états : service coupé (err) · service actif mais modèle pas encore
|
||||
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
|
||||
|
||||
@@ -56,7 +56,7 @@ async function loadPresets(){
|
||||
const row=document.createElement('div');
|
||||
const pend = !x.active && pendingPreset===i+1;
|
||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
||||
row.onclick=()=>switchTo(i+1, x.name);
|
||||
row.onclick=()=>switchTo(i+1, x.name, x.id);
|
||||
const info=document.createElement('div'); info.className='preset-info';
|
||||
const nm=document.createElement('div'); nm.className='preset-name';
|
||||
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
||||
@@ -310,9 +310,10 @@ async function toggleCompact(){
|
||||
const on=document.getElementById('compact-toggle').checked;
|
||||
await jpost('/api/agent/compact',{on});
|
||||
}
|
||||
// Mode mémoire (3 états) — indépendant du mode agent.
|
||||
// Mode mémoire (4 états), réglé PAR PROJET — chaque projet garde le sien.
|
||||
const MEM_DESC={
|
||||
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
|
||||
always:'L\'index des pages est placé en tête de conversation : l\'IA voit d\'emblée ce qu\'elle sait, lit directement la bonne page et sauve d\'elle-même ce qui mérite d\'être retenu. Réglage propre au projet actif.',
|
||||
search:'Rien n\'est injecté : l\'IA cherche dans sa mémoire avant chaque tâche. Contexte plus léger, démarrage plus rapide. Réglage propre au projet actif.',
|
||||
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
|
||||
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
|
||||
};
|
||||
|
||||
@@ -41,14 +41,15 @@ async function runBenchUI(){
|
||||
loadPresets();
|
||||
}
|
||||
}
|
||||
async function switchTo(n,name){
|
||||
async function switchTo(n,name,id){
|
||||
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
|
||||
toast('switching…');
|
||||
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
|
||||
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
|
||||
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
|
||||
pendingPreset = n; loadPresets();
|
||||
const r=await jpost('/api/switch',{n:n});
|
||||
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
|
||||
const r=await jpost('/api/switch',id?{id:id}:{n:n});
|
||||
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
|
||||
toast('switched');
|
||||
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
|
||||
@@ -926,8 +927,47 @@ function populateSettings(){
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mlock', eaHasFlag('--mlock'));
|
||||
chk('s-nommap', eaHasFlag('--no-mmap'));
|
||||
const lm = eaLoadMode();
|
||||
set('s-loadmode', lm);
|
||||
syncLoadModeSub(lm);
|
||||
}
|
||||
// Chargement du modèle. llama.cpp récent a REMPLACÉ --mlock / --no-mmap par
|
||||
// --load-mode (auto, none, mmap, mlock, mmap+mlock, dio). L'éditeur écrit la
|
||||
// forme moderne ; au lancement, loki serve la retraduit pour un moteur ancien
|
||||
// (normalizeLoadFlags côté Go), et inversement.
|
||||
function eaLoadMode(){
|
||||
const lm = (eaGetValued('--load-mode') || eaGetValued('-lm')).toLowerCase();
|
||||
if(lm) return lm === 'auto' ? '' : lm;
|
||||
// Preset écrit avec les anciens drapeaux : même correspondance que le serveur.
|
||||
const mlock = eaHasFlag('--mlock'), nommap = eaHasFlag('--no-mmap');
|
||||
if(mlock && nommap) return 'mlock';
|
||||
if(mlock) return 'mmap+mlock';
|
||||
if(nommap) return 'none';
|
||||
if(eaHasFlag('--mmap')) return 'mmap';
|
||||
return '';
|
||||
}
|
||||
function eaSetLoadMode(mode){
|
||||
const t = eaTokens().filter(x=>x!=='--mlock' && x!=='--no-mmap' && x!=='--mmap' && !x.startsWith('--load-mode='));
|
||||
for(const f of ['--load-mode','-lm']){
|
||||
const i = t.indexOf(f);
|
||||
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
|
||||
}
|
||||
if(mode) t.push('--load-mode', mode);
|
||||
eaSetTokens(t);
|
||||
syncLoadModeSub(mode);
|
||||
}
|
||||
// Sous-titre : ce que fait le mode choisi (les options ne portent que son nom).
|
||||
const LOAD_MODE_SUB = {
|
||||
'': 'mmap, sauf si un périphérique ne le permet pas',
|
||||
'mmap': 'fichier mappé, lu à la demande',
|
||||
'none': 'tout charger, sans mmap',
|
||||
'mlock': 'tout en RAM, verrouillé (sans mmap)',
|
||||
'mmap+mlock': 'mappé et verrouillé en RAM',
|
||||
'dio': 'lecture DirectIO si disponible',
|
||||
};
|
||||
function syncLoadModeSub(mode){
|
||||
const el = document.getElementById('s-loadmode-sub');
|
||||
if(el) el.textContent = LOAD_MODE_SUB[mode] ?? mode;
|
||||
}
|
||||
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
|
||||
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
|
||||
|
||||
@@ -35,15 +35,32 @@ addEventListener('DOMContentLoaded', ()=>{
|
||||
if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c);
|
||||
});
|
||||
|
||||
// iOS ANNULE un appui si la position de défilement change pendant qu'il a lieu.
|
||||
// Le fil se recalant en bas à chaque token pendant la génération, presque chaque
|
||||
// tap tombait dessus : plus rien ne répondait (pas même stop) jusqu'à la fin de
|
||||
// la réponse. On suspend donc le suivi le temps du contact, et on rattrape juste
|
||||
// après (TOUCH_GRACE). Repris d'AJEAN 0.15.7.
|
||||
let TOUCHING = false, _touchEnd = 0;
|
||||
const TOUCH_GRACE = 350;
|
||||
(function(){
|
||||
const on = ()=>{ TOUCHING = true; };
|
||||
const off = ()=>{ TOUCHING = false; _touchEnd = performance.now(); if(stickyBottom) setTimeout(()=>scrollMaybe(), TOUCH_GRACE + 20); };
|
||||
document.addEventListener('touchstart', on, {passive:true, capture:true});
|
||||
document.addEventListener('touchend', off, {passive:true, capture:true});
|
||||
document.addEventListener('touchcancel', off, {passive:true, capture:true});
|
||||
})();
|
||||
function scrollMaybe(){
|
||||
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
|
||||
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
|
||||
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
|
||||
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
|
||||
if(REPLAYING) return;
|
||||
if(stickyBottom){
|
||||
if(stickyBottom && !TOUCHING && performance.now() - _touchEnd >= TOUCH_GRACE){
|
||||
const c = chatEl();
|
||||
c.scrollTop = c.scrollHeight;
|
||||
const target = c.scrollHeight - c.clientHeight;
|
||||
// N'écrire que si l'on n'y est pas déjà : sinon on relance pour rien la
|
||||
// machinerie de défilement d'iOS, qui continue de voler les taps.
|
||||
if(Math.abs(c.scrollTop - target) > 1) c.scrollTop = c.scrollHeight;
|
||||
}
|
||||
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
|
||||
}
|
||||
@@ -245,6 +262,9 @@ function hydrateImages(root){
|
||||
}
|
||||
});
|
||||
}
|
||||
// Lignes d'un contenu en cours d'écriture, comptées comme côté serveur : un saut
|
||||
// de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
|
||||
function bodyLineCount(s){ s=String(s).replace(/\r\n/g,'\n').replace(/\n$/,''); return s ? s.split('\n').length : 0; }
|
||||
// Render a tool call as its own conversation message: the command the model
|
||||
// wrote, then the response it got back. textContent keeps it injection-safe.
|
||||
function renderToolMsg(el, tu){
|
||||
@@ -286,16 +306,18 @@ 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).
|
||||
// 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'; }
|
||||
// Taille RÉELLE (result_chars) : le flux ne porte plus qu'un aperçu.
|
||||
if(tu.result){ const n = tu.result_chars || tu.result.length; lbl += ' · ~' + Math.max(1, Math.round(n/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.
|
||||
// Les vrais totaux viennent du serveur (added/removed) : le diff lui-même est
|
||||
// tronqué pour l'affichage, le recompter donnait « +120 » pour 500 lignes.
|
||||
// Repli sur le décompte du diff pour les conversations d'avant ces champs.
|
||||
let add=0, del=0;
|
||||
if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
|
||||
else if(tu.body){ add=tu.body.split('\n').length; }
|
||||
if(tu.added!=null || tu.removed!=null){ add=tu.added||0; del=tu.removed||0; }
|
||||
else if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
|
||||
else if(tu.body){ add=bodyLineCount(tu.body); }
|
||||
if(add||del) setLabelCounts(el, add, del);
|
||||
const body=bodyOf(el); body.innerHTML='';
|
||||
const head=document.createElement('div'); head.className='tool-head';
|
||||
@@ -369,12 +391,9 @@ function renderToolMsg(el, tu){
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('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).
|
||||
const preview=tu.result, rid=tu.result_id;
|
||||
// result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
|
||||
// « voir plus » CHARGE le reste à la demande (AJEAN 0.15.1 / 0.15.4).
|
||||
if(rid){
|
||||
code.textContent=preview+'…';
|
||||
pre.appendChild(code);
|
||||
@@ -383,29 +402,19 @@ function renderToolMsg(el, tu){
|
||||
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;
|
||||
// Un échec n'est PAS mémorisé : on prévient et on laisse réessayer.
|
||||
more.disabled=true; const prevTxt=more.textContent; more.textContent='…';
|
||||
let got=null;
|
||||
try{ const r=await jfetch('/api/chat/tool-result?id='+encodeURIComponent(rid)); const j=await r.json(); if(r.ok && j && typeof j.result==='string') got=j.result; }
|
||||
catch(_){}
|
||||
more.disabled=false; more.textContent=prevTxt;
|
||||
if(got===null){ toast('résultat complet indisponible'); return; }
|
||||
full=got;
|
||||
}
|
||||
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';
|
||||
more.textContent = open ? 'réduire' : 'voir plus';
|
||||
};
|
||||
body.appendChild(toolBlock(pre, more));
|
||||
} else {
|
||||
@@ -424,7 +433,7 @@ function renderToolMsg(el, tu){
|
||||
}
|
||||
// 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
|
||||
// reste au coin même quand on scrolle dans le résultat. Elle 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';
|
||||
@@ -437,9 +446,8 @@ function toolBlock(pre, more){
|
||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
root.querySelectorAll('pre').forEach(pre=>{
|
||||
// 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.
|
||||
// Résultat d'outil : la barre d'actions est SŒUR du pre (pas dedans) ; on
|
||||
// cherche donc le copier dans le bloc qui les réunit.
|
||||
const scope = pre.closest('.tool-block') || pre;
|
||||
if(scope.querySelector('.copybtn')) return;
|
||||
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
|
||||
@@ -454,8 +462,8 @@ function addCopyButtons(root){
|
||||
btn.textContent='copié ✓'; btn.classList.add('done');
|
||||
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
|
||||
};
|
||||
// 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.
|
||||
// Barre d'actions (résultat d'outil avec « voir plus ») : le copier s'y
|
||||
// range à côté ; sinon il se colle en bas à droite du bloc.
|
||||
(scope.querySelector('.tool-actions')||pre).appendChild(btn);
|
||||
});
|
||||
}
|
||||
@@ -564,6 +572,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(
|
||||
function autoGrow(ta){
|
||||
ta = ta || document.getElementById('input');
|
||||
if(!ta) return;
|
||||
// Du texte à envoyer ? Pendant une réponse, c'est ce qui fait réapparaître
|
||||
// « envoyer » à côté de stop (message mis en file, voir send()).
|
||||
if(ta.id==='input') document.documentElement.setAttribute('data-hastext', ta.value.trim() ? '1' : '0');
|
||||
ta.style.height='auto';
|
||||
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
|
||||
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
|
||||
|
||||
@@ -4,10 +4,39 @@
|
||||
// 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;
|
||||
// Historique paginé (AJEAN 0.15.7) : au chargement, le serveur ne rejoue que les
|
||||
// HIST_TAIL derniers échanges et annonce le reste ({history_more: N}). Le bouton
|
||||
// en haut du fil redemande alors un rejeu complet (HIST_FULL) en gardant la
|
||||
// position de lecture (HIST_RESTORE = distance au bas du fil, réappliquée au
|
||||
// caught_up).
|
||||
const HIST_TAIL=20;
|
||||
let HIST_FULL=false, HIST_RESTORE=null;
|
||||
// Discussion AFFICHÉE (id reçu au caught_up/reset). Renvoyée à chaque
|
||||
// (ré)abonnement : si un autre appareil a changé de discussion pendant une
|
||||
// coupure, le serveur ordonne un reset au lieu de greffer le nouveau fil sur
|
||||
// l'ancien (AJEAN 0.14.0).
|
||||
let CONV_ID='';
|
||||
// File d'attente (AJEAN 0.14.0) : messages envoyés PENDANT une réponse, montrés
|
||||
// en gris au-dessus de la carte jusqu'à ce que le flux les confirme (delta
|
||||
// `user` du même texte) — injectés en cours de réponse ou au tour suivant.
|
||||
function queueAdd(text){
|
||||
const box=document.getElementById('queue-list'); if(!box) return null;
|
||||
const el=document.createElement('div'); el.className='queued-msg'; el.textContent=text||'(pièce jointe)';
|
||||
el.dataset.text=text;
|
||||
box.appendChild(el); box.classList.add('show');
|
||||
return el;
|
||||
}
|
||||
function queueSync(){ const box=document.getElementById('queue-list'); if(box) box.classList.toggle('show', !!box.children.length); }
|
||||
function queueTake(text){
|
||||
const box=document.getElementById('queue-list'); if(!box) return;
|
||||
const el=[...box.children].find(e=>e.dataset.text===text);
|
||||
if(el) el.remove();
|
||||
queueSync();
|
||||
}
|
||||
function queueClear(){ const box=document.getElementById('queue-list'); if(box) box.textContent=''; queueSync(); }
|
||||
// Identifiant d'envoi, stable d'un réessai à l'autre : le serveur ne met pas
|
||||
// deux fois le même message en file si la réponse s'est perdue en route.
|
||||
function newCID(){ try{ return crypto.randomUUID(); }catch(_){ return Date.now().toString(36)+Math.random().toString(36).slice(2); } }
|
||||
// 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
|
||||
@@ -208,7 +237,8 @@ function renderStats(el, s){
|
||||
if(!el||!s) return;
|
||||
const parts=[];
|
||||
const pt=s.prompt_tokens||s.prompt_tokens_total;
|
||||
if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
|
||||
// Débit de lecture inconnu (API tierce sans timings) : on n'affiche que la taille.
|
||||
if(pt) parts.push('prefill '+pt+' tok'+(s.prompt_per_second ? ' · '+s.prompt_per_second.toFixed(0)+' tok/s' : ''));
|
||||
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
|
||||
if(!parts.length) return;
|
||||
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
|
||||
@@ -407,54 +437,38 @@ 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();
|
||||
// Rafraîchissement groupé de la liste des discussions (plusieurs événements
|
||||
// rapprochés = un seul appel).
|
||||
let histRefreshTimer=null;
|
||||
function histRefreshSoon(){
|
||||
if(typeof loadConversations!=='function') return;
|
||||
clearTimeout(histRefreshTimer);
|
||||
histRefreshTimer=setTimeout(()=>{ histRefreshTimer=null; loadConversations(); }, 400);
|
||||
}
|
||||
|
||||
// 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.
|
||||
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
|
||||
if(d.caught_up){
|
||||
if(typeof d.id==='string') CONV_ID=d.id;
|
||||
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
|
||||
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
|
||||
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
|
||||
setChatLoading(null);
|
||||
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
|
||||
if(REPLAYING && HIST_RESTORE!==null){
|
||||
// Historique complet demandé : on garde la position de lecture (distance au
|
||||
// bas du fil) au lieu de sauter en bas, et on la recale une fois la mise en
|
||||
// page stabilisée (le rejeu pose ses blocs de façon différée).
|
||||
REPLAYING=false; syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1';
|
||||
const keep=HIST_RESTORE; HIST_RESTORE=null; stickyBottom=false;
|
||||
const put=()=>{ c.scrollTop=Math.max(0, c.scrollHeight-keep); };
|
||||
put(); requestAnimationFrame(()=>requestAnimationFrame(put)); setTimeout(put, 80); setTimeout(put, 250);
|
||||
} else if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
|
||||
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
|
||||
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
|
||||
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
|
||||
@@ -467,7 +481,9 @@ function handleDelta(d){
|
||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||||
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
||||
if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; }
|
||||
if(d.queue_dropped!==undefined){ if(!REPLAYING){ queueClear(); toast('messages en attente abandonnés'); } return; }
|
||||
if(d.reset!==undefined){ if(typeof d.id==='string') CONV_ID=d.id; queueClear(); HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
||||
// --- Mode code (20-mode.js) -----------------------------------------------
|
||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
||||
@@ -484,6 +500,10 @@ function handleDelta(d){
|
||||
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
|
||||
// bulle de réponse du tour.
|
||||
T.model = d.model || '';
|
||||
if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte
|
||||
// Le serveur a déjà enregistré la discussion (StartTurn persiste) : la liste
|
||||
// la montre dès le premier message, sans attendre la fin de la réponse.
|
||||
if(!REPLAYING) histRefreshSoon();
|
||||
let el=PENDING;
|
||||
if(!confirmPending(d.user)) el=addMsg('user', d.user);
|
||||
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
|
||||
@@ -552,6 +572,17 @@ function handleDelta(d){
|
||||
return; }
|
||||
if(d.reasoning_content){
|
||||
killTyping('reasoning');
|
||||
// Raisonnement qui reprend APRÈS du texte de réponse (modèle qui repense en
|
||||
// cours de réponse, reprise après coupure) : il allait dans l'ANCIENNE bulle,
|
||||
// déjà repliée au-dessus de la réponse. On en ouvre une nouvelle sous la
|
||||
// réponse : l'ordre affiché suit l'ordre reçu (AJEAN 0.17.4).
|
||||
// Réponse encore vide (simple saut de ligne avant la réflexion) : on retire
|
||||
// la bulle vide et le raisonnement continue dans la sienne.
|
||||
if(T.contentEl){
|
||||
if((T.fullContent||'').trim()){ settleBlocks(); T.reasonEl=null; }
|
||||
else { smoothReset(); cancelRender(); T.contentEl.remove(); }
|
||||
T.contentEl=null;
|
||||
}
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
||||
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
||||
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
||||
@@ -595,7 +626,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, full:REPLAY_FULL}),signal:streamAbort.signal});
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL,conv_id:CONV_ID}),signal:streamAbort.signal});
|
||||
if(REPLAYING) setChatLoading('chargement de la conversation…');
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
@@ -620,24 +651,71 @@ async function connectStream(){
|
||||
}
|
||||
// Interrompt la génération en cours côté serveur (la goroutine détachée est
|
||||
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
|
||||
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
|
||||
// _stopAt : anti-doublon, le bouton réagit à l'APPUI sur écran tactile (voir plus
|
||||
// bas) puis le clic qui suit éventuellement ne doit pas relancer.
|
||||
let _stopAt = 0;
|
||||
function stopGen(){
|
||||
const now = Date.now(); if(now - _stopAt < 800) return; _stopAt = now;
|
||||
jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop');
|
||||
}
|
||||
// Sur écran tactile, stop agit dès l'appui (pointerdown) : le « clic » complet
|
||||
// peut être annulé par iOS si le fil défile au même moment — or c'est justement
|
||||
// quand l'IA écrit qu'on veut l'arrêter vite. Repris d'AJEAN 0.15.7.
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const st=document.getElementById('stop'); if(!st) return;
|
||||
st.addEventListener('pointerdown', (e)=>{ if(e.pointerType!=='mouse'){ e.preventDefault(); stopGen(); } });
|
||||
});
|
||||
// Bouton « échanges précédents » posé en tête du fil quand le serveur n'a rejoué
|
||||
// que la fin de la conversation.
|
||||
function showHistoryMore(n){
|
||||
const chat=chatEl(); if(!chat || !n) return;
|
||||
let box=chat.querySelector('.history-more');
|
||||
if(!box){ box=document.createElement('div'); box.className='history-more'; chat.insertBefore(box, chat.firstChild); }
|
||||
box.innerHTML='';
|
||||
const b=document.createElement('button'); b.type='button';
|
||||
b.textContent = n===1 ? 'afficher l\'échange précédent' : 'afficher les '+n+' échanges précédents';
|
||||
b.onclick=loadFullHistory;
|
||||
box.appendChild(b);
|
||||
}
|
||||
// Rejeu COMPLET de la conversation vive, en gardant ce qu'on lisait à l'écran :
|
||||
// on vide le fil, on coupe le flux, et la boucle connectStream se reconnecte
|
||||
// depuis le début sans pagination (tail=0).
|
||||
function loadFullHistory(){
|
||||
const chat=chatEl(); if(!chat) return;
|
||||
const box=chat.querySelector('.history-more'); if(box){ const b=box.querySelector('button'); if(b){ b.disabled=true; b.textContent='chargement…'; } }
|
||||
HIST_RESTORE=chat.scrollHeight-chat.scrollTop;
|
||||
HIST_FULL=true;
|
||||
chat.innerHTML=''; newTurn();
|
||||
smoothReset(); cancelRender();
|
||||
lastSeq=0; REPLAYING=true;
|
||||
if(streamAbort){ try{ streamAbort.abort(); }catch(e){} }
|
||||
}
|
||||
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
|
||||
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
|
||||
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
|
||||
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
|
||||
// « network error » alarmiste alors que l'IA répond quand même.
|
||||
// Fuseau du navigateur, envoyé avec chaque message : le conteneur tourne en UTC,
|
||||
// et « rappelle-moi à 17h » doit tomber à 17h ici (voir rememberUserTZ).
|
||||
const USER_TZ=(()=>{ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||''; }catch(_){ return ''; } })();
|
||||
async function send(){
|
||||
if(busy) return;
|
||||
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
|
||||
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
|
||||
const ta=document.getElementById('input'); const text=ta.value.trim();
|
||||
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
|
||||
if(!text && !ATTACH.length) return;
|
||||
ta.value=''; autoGrow(ta);
|
||||
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
|
||||
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
|
||||
addPending(text);
|
||||
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
|
||||
// Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en
|
||||
// compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche
|
||||
// en attente au-dessus de la carte, pas dans le fil qui s'écrit encore.
|
||||
const queuedAtSend = busy;
|
||||
let qel=null;
|
||||
// Sinon le message s'affiche TOUT DE SUITE dans le fil, en gris : il ne
|
||||
// disparaît plus le temps de l'aller-retour. Il s'éclaircit quand le flux le
|
||||
// confirme (confirmPending).
|
||||
if(queuedAtSend) qel=queueAdd(text); else addPending(text);
|
||||
const fail=(m)=>{ if(qel){ qel.remove(); queueSync(); } else clearPending(); toast(m); ta.value=text; autoGrow(ta); };
|
||||
const cid=newCID();
|
||||
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
|
||||
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
|
||||
// on doit pouvoir en enlever une, et un échec doit rester visible.
|
||||
@@ -645,13 +723,20 @@ async function send(){
|
||||
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
|
||||
// Les pastilles passent dans la bulle en attente : le message porte ses
|
||||
// fichiers dès l'envoi, sans attendre l'aller-retour.
|
||||
if(PENDING) addMsgFiles(PENDING, attachSent());
|
||||
if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent());
|
||||
for(let attempt=0; attempt<3; attempt++){
|
||||
try{
|
||||
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
|
||||
if(r.status===409 || r.ok) clearAttach();
|
||||
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
|
||||
if(r.ok) return; // la bulle + les tokens arrivent par le flux
|
||||
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid,tz:USER_TZ})});
|
||||
if(r.ok) clearAttach();
|
||||
// 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
|
||||
if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
|
||||
if(r.ok){
|
||||
// Parti tout de suite alors qu'on le croyait en file (le tour venait de
|
||||
// finir) : la pastille disparaît, la bulle arrive par le flux.
|
||||
let j={}; try{ j=await r.json(); }catch(_){}
|
||||
if(qel && j.queued===false){ qel.remove(); queueSync(); }
|
||||
return; // la bulle + les tokens arrivent par le flux
|
||||
}
|
||||
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
|
||||
}catch(e){ /* réseau : on retente */ }
|
||||
await new Promise(res=>setTimeout(res, 600));
|
||||
|
||||
@@ -27,6 +27,7 @@ async function openExternal(id){
|
||||
document.getElementById('ext-test-out').textContent = '';
|
||||
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
||||
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
||||
document.getElementById('ext-vision').checked = false;
|
||||
showModal('ext-modal');
|
||||
if(!id) return;
|
||||
let d = {};
|
||||
@@ -34,6 +35,7 @@ async function openExternal(id){
|
||||
if(extEditing !== id) return; // une autre ouverture a pris la main
|
||||
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
||||
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
||||
document.getElementById('ext-vision').checked = !!d.vision;
|
||||
if(d.hasKey){
|
||||
document.getElementById('ext-key-sub').textContent =
|
||||
'une clé est enregistrée — laisse vide pour la conserver';
|
||||
@@ -50,6 +52,7 @@ function extPayload(){
|
||||
key: document.getElementById('ext-key').value || '',
|
||||
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
||||
keyTouched: extKeyTouched,
|
||||
vision: document.getElementById('ext-vision').checked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,9 +82,11 @@ async function saveExternal(){
|
||||
if(!p.model){ toast('modèle requis'); return; }
|
||||
const r = await jpost('/api/preset/external/save', p);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
toast('enregistré');
|
||||
// Preset en service : le serveur l'a appliqué aussitôt (applied).
|
||||
toast(r.applied ? 'enregistré et appliqué' : 'enregistré');
|
||||
closeExternal();
|
||||
loadPresets();
|
||||
if(r.applied && typeof loadStatus === 'function') loadStatus();
|
||||
}
|
||||
|
||||
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
||||
|
||||
@@ -19,6 +19,12 @@ let CONVS = [], CONV_ACTIVE = '';
|
||||
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
|
||||
// route, ou ouverte depuis un autre appareil).
|
||||
let CONV_BUSY = false;
|
||||
// Liste paginée (AJEAN 0.15.6) : avec des centaines de discussions, construire
|
||||
// toutes les lignes d'un coup figeait l'ouverture du panneau. On en dessine
|
||||
// CONV_PAGE, puis la suite quand le bas de la liste devient visible. La
|
||||
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
|
||||
const CONV_PAGE = 40;
|
||||
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
|
||||
|
||||
async function loadConversations(){
|
||||
let r;
|
||||
@@ -41,6 +47,7 @@ function renderConversations(){
|
||||
if(!cont) return;
|
||||
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
|
||||
cont.textContent = '';
|
||||
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
|
||||
const list = q
|
||||
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
|
||||
: CONVS;
|
||||
@@ -53,7 +60,10 @@ function renderConversations(){
|
||||
convSyncBusy();
|
||||
return;
|
||||
}
|
||||
for(const c of list){
|
||||
// La discussion active reste toujours visible, même loin dans la liste.
|
||||
const ai = list.findIndex(c => c.id === CONV_ACTIVE);
|
||||
const shown = Math.max(CONV_SHOWN, ai + 1);
|
||||
for(const c of list.slice(0, shown)){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
|
||||
@@ -91,10 +101,28 @@ function renderConversations(){
|
||||
row.append(info, when, acts);
|
||||
cont.appendChild(row);
|
||||
}
|
||||
if(list.length > shown) convMoreRow(cont, list.length - shown);
|
||||
syncTopbarTitle();
|
||||
convSyncBusy();
|
||||
}
|
||||
|
||||
// Ligne « N de plus » en bas de la liste : un clic, ou son arrivée à l'écran
|
||||
// (IntersectionObserver), dessine la page suivante.
|
||||
function convMoreRow(cont, left){
|
||||
const more = document.createElement('button');
|
||||
more.type = 'button';
|
||||
more.className = 'conv-more';
|
||||
more.textContent = 'afficher ' + Math.min(left, CONV_PAGE) + ' de plus (' + left + ' restantes)';
|
||||
const next = () => { CONV_SHOWN += CONV_PAGE; renderConversations(); };
|
||||
more.onclick = next;
|
||||
cont.appendChild(more);
|
||||
if(convMoreObs) convMoreObs.disconnect();
|
||||
if(window.IntersectionObserver){
|
||||
convMoreObs = new IntersectionObserver(es => { if(es.some(e => e.isIntersecting)){ convMoreObs.disconnect(); next(); } });
|
||||
convMoreObs.observe(more);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Discussion au travail : anneau qui tourne ------------------------------
|
||||
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
|
||||
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
|
||||
|
||||
@@ -65,7 +65,7 @@ function onModelSwitch(sel){
|
||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
|
||||
// pendant les quelques secondes du redémarrage.
|
||||
switchTo(n, name);
|
||||
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
|
||||
}
|
||||
|
||||
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||
@@ -143,6 +143,7 @@ function paintModelSwitch(){
|
||||
MS_PRESETS.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(i + 1);
|
||||
o.dataset.id = p.id || '';
|
||||
o.textContent = p.name;
|
||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||
g.appendChild(o);
|
||||
|
||||
@@ -110,7 +110,7 @@ function renderCriteria(list){
|
||||
for(const c of list){
|
||||
const li = document.createElement('li');
|
||||
li.className = 'crit-'+(c.status||'pending');
|
||||
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
|
||||
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : (c.status==='completed' ? '◐' : '○'));
|
||||
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
@@ -443,29 +443,19 @@ 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 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}
|
||||
/* Résultat d'outil : « voir plus » + « copier » réunis dans une barre ancrée en
|
||||
bas à droite. Le pre scrolle (max-height), donc la barre est portée par le
|
||||
bloc-parent NON scrollant (.tool-block) : elle reste au coin. Déplié, le pre
|
||||
lève son plafond pour montrer VRAIMENT plus (AJEAN 0.15.4). */
|
||||
.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-actions .tool-more{margin:0;padding:3px 8px;font:inherit;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;cursor:pointer;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 .tool-actions .tool-more:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.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}
|
||||
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
|
||||
@@ -1220,6 +1210,19 @@ html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
|
||||
#attach-list::-webkit-scrollbar{display:none}
|
||||
#attach-list.show{display:flex}
|
||||
#composer:has(#attach-list.show){--fade:6px}
|
||||
/* File d'attente : messages envoyés pendant une réponse, pas encore pris en
|
||||
compte. Même bande que les pièces jointes, en gris. */
|
||||
#queue-list{order:0;display:none;flex-direction:column;align-items:flex-end;gap:4px;margin:0 var(--col);padding:0 2px 8px}
|
||||
#queue-list.show{display:flex}
|
||||
#composer:has(#queue-list.show){--fade:6px}
|
||||
#queue-list .queued-msg{max-width:80%;padding:5px 12px;border-radius:12px;background:var(--panel);
|
||||
border:1px dashed var(--border);color:var(--dim);font-size:12px;line-height:1.35;
|
||||
white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
|
||||
box-shadow:0 2px 10px rgba(0,0,0,.10)}
|
||||
#queue-list .queued-msg::before{content:"en attente · ";font-style:italic;opacity:.8}
|
||||
/* Pendant une réponse, envoyer reste possible dès qu'il y a du texte : le
|
||||
message part en file (voir send()). Stop reste à côté. */
|
||||
html[data-busy="1"][data-hastext="1"] #send{display:inline-flex}
|
||||
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
|
||||
cadre, sinon le texte de la conversation transparaît derrière. */
|
||||
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
|
||||
@@ -1711,6 +1714,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
color:var(--dim);letter-spacing:-.01em;overflow:visible}
|
||||
.side .preset:hover .conv-when{opacity:0}
|
||||
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
|
||||
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
|
||||
border:0;padding:8px 11px;text-align:left;cursor:pointer}
|
||||
.conv-more:hover{color:var(--text)}
|
||||
/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
|
||||
élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
|
||||
agent est en train de travailler, y compris depuis un autre appareil. */
|
||||
@@ -2102,6 +2108,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#criteria-list li.crit-passed{color:var(--accent)}
|
||||
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
|
||||
#criteria-list li.crit-pending{color:var(--dim)}
|
||||
#criteria-list li.crit-completed{color:var(--text)}
|
||||
|
||||
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
||||
|
||||
@@ -2207,3 +2214,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
.md-report table{border-collapse:collapse;margin:.5em 0}
|
||||
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
|
||||
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
|
||||
|
||||
/* Historique paginé : bouton « échanges précédents » en tête du fil. */
|
||||
#chat .history-more{display:flex;justify-content:center;padding:6px 0 14px}
|
||||
#chat .history-more button{font:inherit;font-size:12px;color:var(--dim);background:var(--row-bg);
|
||||
border:1px solid var(--border);border-radius:999px;padding:6px 14px;cursor:pointer;
|
||||
transition:background .15s,color .15s,border-color .15s}
|
||||
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
||||
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
||||
+54
-24
@@ -80,6 +80,9 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon
|
||||
"load_pct": loadPct, // % estimé du chargement en cours (-1 = non mesurable)
|
||||
"external": external, // preset externe : le chat part vers une API distante
|
||||
// id du preset actif : un autre appareil a pu basculer, l'UI se
|
||||
// resynchronise sans reload (voir loadStatus). AJEAN 0.13.6.
|
||||
"preset": activePresetID(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -399,7 +402,8 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
|
||||
out := []map[string]any{}
|
||||
for _, p := range list {
|
||||
item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active}
|
||||
if content, err := ReadPreset(p.ID); err == nil {
|
||||
content, _ := ReadPreset(p.ID)
|
||||
if content != "" {
|
||||
// Preset externe : ni quant, ni raisonnement local à annoncer — on
|
||||
// l'étiquette et on donne le nom du modèle distant à afficher.
|
||||
if cfg := parseEnv(content); isExternalConfig(cfg) {
|
||||
@@ -417,7 +421,7 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
|
||||
item["reasoning"] = strings.ToLower(r)
|
||||
}
|
||||
}
|
||||
if sb, ok := store[p.ID]; ok {
|
||||
if sb, ok := store[p.ID]; ok && benchMatchesPreset(sb, parseEnv(content)) {
|
||||
item["bench"] = map[string]any{
|
||||
"prefill": sb.Result.PromptPerSecond,
|
||||
"decode": sb.Result.PredictedPerSec,
|
||||
@@ -548,16 +552,8 @@ func handleMemoryMode(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
// On normalise via memMode() en réinjectant la valeur : toute entrée
|
||||
// inconnue retombe sur "always", donc on valide en passant par le parseur.
|
||||
m := MemAlways
|
||||
switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) {
|
||||
case MemOff:
|
||||
m = MemOff
|
||||
case MemOnDemand:
|
||||
m = MemOnDemand
|
||||
case MemAlways:
|
||||
m = MemAlways
|
||||
}
|
||||
if err := setMemMode(m); err != nil {
|
||||
// Réglage du PROJET ACTIF (AJEAN 0.13.12).
|
||||
if err := setMemMode(normalizeMemMode(req.Mode)); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -761,7 +757,10 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
setMemDEK(dek)
|
||||
resumeMemMigration() // si une migration attendait le déverrouillage
|
||||
resumeMemMigration() // si une migration attendait le déverrouillage
|
||||
// AVANT le rechargement : la discussion active est désignée par une clé
|
||||
// qu'une version précédente chiffrait par erreur (voir plainStoreKey).
|
||||
healPlainStoreKeys()
|
||||
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.
|
||||
@@ -1223,8 +1222,14 @@ func handleModelUse(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
// id = identité stable du preset (nom de fichier). Le numéro seul est
|
||||
// dangereux : si la liste a changé depuis l'affichage (réordonnée, preset
|
||||
// ajouté ou supprimé depuis un autre appareil), la même position désigne un
|
||||
// AUTRE preset, et on chargeait le mauvais (AJEAN 0.17.4). n reste accepté
|
||||
// pour les anciens clients.
|
||||
var req struct {
|
||||
N int `json:"n"`
|
||||
N int `json:"n"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
@@ -1235,11 +1240,26 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.N < 1 || req.N > len(list) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
|
||||
return
|
||||
var target Preset
|
||||
if id := strings.TrimSpace(req.ID); id != "" {
|
||||
found := false
|
||||
for _, p := range list {
|
||||
if p.ID == id {
|
||||
target, found = p, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "preset introuvable (supprimé ou renommé ?)"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if req.N < 1 || req.N > len(list) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
|
||||
return
|
||||
}
|
||||
target = list[req.N-1]
|
||||
}
|
||||
target := list[req.N-1]
|
||||
// On écrit config.env TOUT DE SUITE (c'est lui qui décide du preset actif),
|
||||
// puis on répond — le redémarrage du service part en arrière-plan. Passer par
|
||||
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
|
||||
@@ -1336,6 +1356,9 @@ func handleBenchLast(w http.ResponseWriter, r *http.Request) {
|
||||
// le décodeur JSON ignore les champs qu'il ne connaît pas.
|
||||
type chatReq struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
// TZ = fuseau IANA du navigateur (« Europe/Paris »), retenu pour dater ce
|
||||
// que le modèle planifie. Voir rememberUserTZ.
|
||||
TZ string `json:"tz"`
|
||||
// Surcharges par requête, portées par les agents ajean.link qui ont leurs
|
||||
// propres interrupteurs. nil = on prend la configuration de la machine.
|
||||
// ⚠️ Elles ne peuvent que RESTREINDRE, jamais rallumer (voir capsFromBody).
|
||||
@@ -1349,12 +1372,19 @@ 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"`
|
||||
// Tail (flux d'abonnement) : nombre de derniers ÉCHANGES à rejouer au
|
||||
// chargement ; 0 = tout. nil = ancien client (app relais…), qui ne sait pas
|
||||
// afficher le bouton « échanges précédents » : il reçoit toujours tout.
|
||||
Tail *int `json:"tail"`
|
||||
// ClientID = identifiant unique d'un ENVOI (/api/chat/send), stable d'un
|
||||
// réessai à l'autre : le serveur ne met pas deux fois le même message en
|
||||
// file (voir Conversation.recentCIDs).
|
||||
ClientID string `json:"cid"`
|
||||
// ConvID = id de la discussion AFFICHÉE par le client (reçu au dernier
|
||||
// caught_up/reset du flux). Renvoyé à chaque (re)abonnement pour que le
|
||||
// serveur détecte qu'un AUTRE appareil a changé de discussion entre-temps
|
||||
// et ordonne un reset avant de rejouer — sinon deux fils fusionnaient.
|
||||
ConvID string `json:"conv_id"`
|
||||
// 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
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -46,6 +49,10 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
map[string]any{"error": "configuration illisible — réessaie dans un instant"})
|
||||
return
|
||||
}
|
||||
if msg := crossSiteReject(r, key != ""); msg != "" {
|
||||
sendJSON(w, http.StatusForbidden, map[string]any{"error": msg})
|
||||
return
|
||||
}
|
||||
if key == "" {
|
||||
next(w, r)
|
||||
return
|
||||
@@ -166,3 +173,71 @@ func cmdSetWebKey(args []string) error {
|
||||
fmt.Printf(" (relance 'loki web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// crossSiteReject refuse les requêtes qu'un SITE TIERS ouvert dans le navigateur
|
||||
// (de la machine ou du réseau local) ferait en douce vers l'API. Sans clé (le
|
||||
// défaut), l'API est ouverte : une page malveillante pouvait envoyer un POST
|
||||
// « simple » (text/plain, sans pré-vérification CORS) à <serveur>:8090 et
|
||||
// piloter Loki, jusqu'à faire exécuter des commandes à l'agent. Renvoie "" si
|
||||
// la requête est acceptable, sinon la raison du refus. Repris d'AJEAN 0.15.5.
|
||||
//
|
||||
// - Sec-Fetch-Site: cross-site → refus (navigateurs récents, couvre aussi les
|
||||
// GET déclenchés par une balise <img>/<form>).
|
||||
// - Origin présent et différent de l'hôte appelé → refus (tous les navigateurs
|
||||
// envoient Origin sur un POST cross-origin). Les clients hors navigateur
|
||||
// (scripts, curl, apps) n'envoient pas d'Origin : non concernés.
|
||||
// - Sans clé seulement : l'hôte appelé doit être local (IP, localhost, nom de
|
||||
// la machine, nom sans point, .local/.lan…) ou listé dans
|
||||
// LOKI_TRUSTED_HOSTS. Bloque le « DNS rebinding », où un domaine malveillant
|
||||
// se fait résoudre en IP locale pour paraître même-origine. Avec une clé,
|
||||
// inutile : le navigateur n'envoie jamais le Bearer tout seul. Le trafic du
|
||||
// tunnel (authentifié par le relais) est lui aussi dispensé de ce contrôle.
|
||||
func crossSiteReject(r *http.Request, keyed bool) string {
|
||||
if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") {
|
||||
return "requête d'un site tiers refusée"
|
||||
}
|
||||
if o := r.Header.Get("Origin"); o != "" && o != "null" {
|
||||
u, err := url.Parse(o)
|
||||
if err != nil || !strings.EqualFold(u.Host, r.Host) {
|
||||
return "origine non autorisée : " + o
|
||||
}
|
||||
} else if o == "null" {
|
||||
return "origine non autorisée"
|
||||
}
|
||||
if !keyed && r.Header.Get(viaTunnelHeader) != "tunnel" && !localHostName(r.Host) {
|
||||
return "hôte « " + r.Host + " » non autorisé sans clé de pilotage — définis-en une (loki set-web-key) ou ajoute ce nom à LOKI_TRUSTED_HOSTS"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// localHostName : l'hôte désigne-t-il la machine ou le réseau local (et non un
|
||||
// domaine public, seul utilisable pour un DNS rebinding) ?
|
||||
func localHostName(hostport string) bool {
|
||||
h := hostport
|
||||
if hh, _, err := net.SplitHostPort(hostport); err == nil {
|
||||
h = hh
|
||||
}
|
||||
h = strings.ToLower(strings.Trim(h, "[]."))
|
||||
if h == "" || net.ParseIP(h) != nil || !strings.Contains(h, ".") {
|
||||
return true
|
||||
}
|
||||
for _, suf := range []string{".localhost", ".local", ".lan", ".home", ".internal", ".home.arpa", ".localdomain"} {
|
||||
if strings.HasSuffix(h, suf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if me, err := os.Hostname(); err == nil && me != "" {
|
||||
me = strings.ToLower(me)
|
||||
if h == me || strings.HasPrefix(h, me+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Échappatoire du conteneur : derrière un reverse proxy (loki.mondomaine.fr),
|
||||
// le nom public n'est ni local ni celui du conteneur.
|
||||
for _, t := range strings.Split(os.Getenv("LOKI_TRUSTED_HOSTS"), ",") {
|
||||
if t = strings.ToLower(strings.TrimSpace(t)); t != "" && h == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossSiteReject(t *testing.T) {
|
||||
t.Setenv("LOKI_TRUSTED_HOSTS", "loki.exemple.fr")
|
||||
cas := []struct {
|
||||
nom string
|
||||
host, origin string
|
||||
fetchSite string
|
||||
tunnel bool
|
||||
keyed bool
|
||||
refuse bool
|
||||
}{
|
||||
{nom: "curl sur IP", host: "192.168.1.10:8090"},
|
||||
{nom: "localhost", host: "localhost:8090", origin: "http://localhost:8090"},
|
||||
{nom: "nom sans point", host: "tower:8090", origin: "http://tower:8090"},
|
||||
{nom: "nom .local", host: "tower.local:8090"},
|
||||
{nom: "origine tierce", host: "192.168.1.10:8090", origin: "http://evil.example", refuse: true},
|
||||
{nom: "origine null", host: "localhost:8090", origin: "null", refuse: true},
|
||||
{nom: "sec-fetch cross-site", host: "localhost:8090", fetchSite: "cross-site", refuse: true},
|
||||
{nom: "rebinding sans clé", host: "evil.example:8090", origin: "http://evil.example:8090", refuse: true},
|
||||
{nom: "domaine avec clé", host: "evil.example:8090", keyed: true},
|
||||
{nom: "domaine de confiance", host: "loki.exemple.fr", origin: "https://loki.exemple.fr"},
|
||||
{nom: "tunnel", host: "relais.exemple.net", tunnel: true},
|
||||
}
|
||||
for _, c := range cas {
|
||||
r := httptest.NewRequest("POST", "/api/status", nil)
|
||||
r.Host = c.host
|
||||
if c.origin != "" {
|
||||
r.Header.Set("Origin", c.origin)
|
||||
}
|
||||
if c.fetchSite != "" {
|
||||
r.Header.Set("Sec-Fetch-Site", c.fetchSite)
|
||||
}
|
||||
if c.tunnel {
|
||||
r.Header.Set(viaTunnelHeader, "tunnel")
|
||||
}
|
||||
if got := crossSiteReject(r, c.keyed) != ""; got != c.refuse {
|
||||
t.Errorf("%s : refus=%v, attendu %v", c.nom, got, c.refuse)
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-33
@@ -84,7 +84,11 @@ 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, body.Full, emit)
|
||||
tail := -1
|
||||
if body.Tail != nil {
|
||||
tail = *body.Tail
|
||||
}
|
||||
conv.SubscribeTail(ctx, body.From, tail, body.ConvID, emit)
|
||||
}
|
||||
|
||||
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse
|
||||
@@ -102,8 +106,18 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"})
|
||||
return
|
||||
}
|
||||
if err := conv.StartTurn(body.Message, files, capsFromBody(body), body.Temperature); err != nil {
|
||||
// 409 = occupé (génération en cours) ; 503 = modèle pas prêt.
|
||||
rememberUserTZ(body.TZ)
|
||||
// Génération en cours : au lieu de refuser, on MET EN FILE (AJEAN 0.14.0).
|
||||
// Le message est injecté dans la réponse en cours à la prochaine frontière
|
||||
// d'étape, ou traité comme tour suivant. queued=true le signale au client.
|
||||
queued, err := conv.EnqueueOrStart(body.ClientID, body.Message, files, capsFromBody(body), body.Temperature)
|
||||
if err == ErrDupSend {
|
||||
// Réessai d'un envoi déjà accepté (réponse perdue en route) : succès.
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "dup": true})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// 409 = une tâche planifiée occupe le modèle ; 503 = modèle pas prêt.
|
||||
code := 503
|
||||
if err == ErrBusy {
|
||||
code = 409
|
||||
@@ -112,7 +126,23 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "queued": queued})
|
||||
}
|
||||
|
||||
// handleToolResult renvoie le résultat COMPLET d'un outil dont le flux n'a
|
||||
// transporté qu'un aperçu (bouton « voir plus », voir tool_results.go).
|
||||
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
|
||||
}
|
||||
s, ok := loadToolResult(id)
|
||||
if !ok {
|
||||
sendJSON(w, 404, map[string]any{"error": "résultat non disponible"})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"result": s})
|
||||
}
|
||||
|
||||
func handleChatStop(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -202,35 +232,6 @@ 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 {
|
||||
|
||||
@@ -49,6 +49,7 @@ func handlePresetExternal(w http.ResponseWriter, r *http.Request) {
|
||||
"model": strings.TrimSpace(cfg[extKeyModel]),
|
||||
"ctx": strings.TrimSpace(cfg["CTX"]),
|
||||
"hasKey": strings.TrimSpace(cfg[extKeyToken]) != "",
|
||||
"vision": strings.TrimSpace(cfg[extKeyVision]) == "1",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ type externalSaveReq struct {
|
||||
Key string `json:"key"`
|
||||
Ctx string `json:"ctx"` // taille de contexte (clé CTX), optionnelle
|
||||
KeyTouched bool `json:"keyTouched"` // l'utilisateur a modifié le champ clé
|
||||
Vision bool `json:"vision"` // le modèle distant accepte les images
|
||||
}
|
||||
|
||||
func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -94,13 +96,15 @@ func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
|
||||
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
|
||||
}
|
||||
}
|
||||
content := externalPresetContent(req.URL, req.Model, key, req.Ctx)
|
||||
newID, err := SavePreset(req.ID, req.Name, content)
|
||||
content := externalPresetContent(req.URL, req.Model, key, req.Ctx, req.Vision)
|
||||
// Éditer le preset EN SERVICE l'applique tout de suite : sans ça, une URL ou
|
||||
// une clé corrigée ne servait qu'après une bascule aller-retour.
|
||||
newID, applied, err := SavePresetApplying(req.ID, req.Name, content)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name, "applied": applied})
|
||||
}
|
||||
|
||||
// handlePresetExternalTest tente un appel de complétion minimal vers l'endpoint
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// web_gzip.go — compression gzip des réponses du serveur web LOCAL.
|
||||
//
|
||||
// L'interface (≈ 850 Ko de HTML/JS/CSS) et les réponses JSON volumineuses (liste
|
||||
// des sessions, résultats d'outils, journal d'une conversation relue) partaient en
|
||||
// clair. gzip les divise typiquement par 4 à 8, ce qui se sent sur un téléphone en
|
||||
// Wi-Fi. La décision se prend au PREMIER octet, d'après le Content-Type choisi par
|
||||
// le handler : les flux (text/event-stream), les binaires et ce qui est déjà
|
||||
// compressé passent tels quels — un flux SSE compressé retiendrait ses événements
|
||||
// dans le tampon de gzip au lieu de les livrer en direct.
|
||||
|
||||
var gzPool = sync.Pool{New: func() any { w, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed); return w }}
|
||||
|
||||
func gzipHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || r.Method == http.MethodHead {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
gw := &gzipWriter{ResponseWriter: w}
|
||||
defer gw.finish()
|
||||
next.ServeHTTP(gw, r)
|
||||
})
|
||||
}
|
||||
|
||||
type gzipWriter struct {
|
||||
http.ResponseWriter
|
||||
decided bool
|
||||
gz *gzip.Writer
|
||||
}
|
||||
|
||||
// compressible : types textuels uniquement, jamais un flux ni un contenu déjà encodé.
|
||||
func compressible(h http.Header) bool {
|
||||
if h.Get("Content-Encoding") != "" {
|
||||
return false
|
||||
}
|
||||
ct := strings.ToLower(h.Get("Content-Type"))
|
||||
if strings.HasPrefix(ct, "text/event-stream") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(ct, "text/") || strings.Contains(ct, "json") ||
|
||||
strings.Contains(ct, "javascript") || strings.Contains(ct, "xml") || strings.Contains(ct, "manifest")
|
||||
}
|
||||
|
||||
func (g *gzipWriter) decide() {
|
||||
if g.decided {
|
||||
return
|
||||
}
|
||||
g.decided = true
|
||||
h := g.Header()
|
||||
h.Add("Vary", "Accept-Encoding")
|
||||
if !compressible(h) {
|
||||
return
|
||||
}
|
||||
h.Set("Content-Encoding", "gzip")
|
||||
h.Del("Content-Length")
|
||||
gz := gzPool.Get().(*gzip.Writer)
|
||||
gz.Reset(g.ResponseWriter)
|
||||
g.gz = gz
|
||||
}
|
||||
|
||||
func (g *gzipWriter) WriteHeader(code int) {
|
||||
// Pas de corps pour ces statuts : ne jamais annoncer un encodage.
|
||||
if code == http.StatusNoContent || code == http.StatusNotModified || code < 200 {
|
||||
g.decided = true
|
||||
}
|
||||
g.decide()
|
||||
g.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (g *gzipWriter) Write(b []byte) (int, error) {
|
||||
g.decide()
|
||||
if g.gz != nil {
|
||||
return g.gz.Write(b)
|
||||
}
|
||||
return g.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush : les handlers qui diffusent (SSE) doivent continuer à pouvoir vider leur
|
||||
// tampon ; dans ce cas ils ne sont de toute façon pas compressés (voir compressible).
|
||||
func (g *gzipWriter) Flush() {
|
||||
if g.gz != nil {
|
||||
_ = g.gz.Flush()
|
||||
}
|
||||
if f, ok := g.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap laisse http.ResponseController atteindre le writer d'origine.
|
||||
func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }
|
||||
|
||||
func (g *gzipWriter) finish() {
|
||||
if g.gz != nil {
|
||||
_ = g.gz.Close()
|
||||
g.gz.Reset(nil)
|
||||
gzPool.Put(g.gz)
|
||||
g.gz = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// JSON compressé, flux SSE et binaires laissés tels quels.
|
||||
func TestGzipHandler(t *testing.T) {
|
||||
h := gzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/json":
|
||||
sendJSON(w, 200, map[string]any{"x": strings.Repeat("a", 5000)})
|
||||
case "/sse":
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte("data: 1\n\n"))
|
||||
w.(http.Flusher).Flush()
|
||||
case "/bin":
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Write([]byte{1, 2, 3})
|
||||
}
|
||||
}))
|
||||
get := func(p string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", p, nil)
|
||||
r.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, r)
|
||||
return rec
|
||||
}
|
||||
j := get("/json")
|
||||
if j.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Fatal("JSON non compressé")
|
||||
}
|
||||
zr, err := gzip.NewReader(j.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(zr)
|
||||
if !strings.Contains(string(b), strings.Repeat("a", 5000)) || j.Body.Len() > 500 {
|
||||
t.Fatalf("décompression/ratio inattendus (%d o compressés)", j.Body.Len())
|
||||
}
|
||||
if s := get("/sse"); s.Header().Get("Content-Encoding") != "" || s.Body.String() != "data: 1\n\n" || !s.Flushed {
|
||||
t.Fatal("le flux SSE ne doit pas être compressé et doit rester vidable")
|
||||
}
|
||||
if b := get("/bin"); b.Header().Get("Content-Encoding") != "" {
|
||||
t.Fatal("binaire compressé à tort")
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,9 @@ func cmdWeb(args []string) error {
|
||||
// restent ouverts aussi longtemps que l'utilisateur regarde la page — et
|
||||
// depuis mountOAI, il couperait aussi les complétions en streaming des
|
||||
// clients tiers, en plein milieu d'une génération.
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||
// gzipHandler : l'UI (~590 Ko) et les gros JSON partent compressés ; les flux
|
||||
// SSE et les binaires passent tels quels (voir web_gzip.go).
|
||||
srv := &http.Server{Handler: gzipHandler(mux), ReadHeaderTimeout: 10 * time.Second}
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
|
||||
@@ -330,6 +332,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
||||
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||
api("/api/chat/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
|
||||
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
||||
// Discussions : liste, création, bascule, renommage, suppression.
|
||||
api("/api/conversations", handleConvList)
|
||||
@@ -337,13 +340,12 @@ 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/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)
|
||||
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)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La bascule vise le preset par son id, pas par sa position : une liste
|
||||
// réordonnée sur un autre appareil faisait charger un AUTRE preset. Presets
|
||||
// externes ici : la bascule n'a alors aucun moteur local à redémarrer.
|
||||
func TestBasculeParIdentifiant(t *testing.T) {
|
||||
testHome(t)
|
||||
if _, err := SavePreset("", "Alpha", externalPresetContent("https://a.example/v1", "modele-a", "", "", false)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idB, err := SavePreset("", "Beta", externalPresetContent("https://b.example/v1", "modele-b", "", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := func(body string) int {
|
||||
w := httptest.NewRecorder()
|
||||
handleSwitch(w, httptest.NewRequest("POST", "/api/switch", strings.NewReader(body)))
|
||||
return w.Code
|
||||
}
|
||||
if code := post(`{"id":"` + idB + `","n":1}`); code != 200 {
|
||||
t.Fatalf("bascule par id : code %d", code)
|
||||
}
|
||||
if got := ReadConfig()[extKeyModel]; got != "modele-b" {
|
||||
t.Fatalf("preset chargé = %q, attendu celui de l'id (modele-b) et non la position 1", got)
|
||||
}
|
||||
if code := post(`{"id":"inexistant"}`); code != 404 {
|
||||
t.Fatalf("id inconnu : code %d, attendu 404", code)
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,9 @@ func imageMime(name string) string {
|
||||
// seule où envoyer une image AU MODÈLE a un sens. Sans projecteur, llama-server
|
||||
// rejetterait un contenu image ; on s'en tient alors au dépôt-fichier.
|
||||
func visionEnabled() bool {
|
||||
if externalActive() {
|
||||
return externalVisionActive() // modèle distant déclaré multimodal
|
||||
}
|
||||
return strings.TrimSpace(ReadConfig()["MMPROJ"]) != ""
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user