mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847aad217b | ||
|
|
e602adbbee | ||
|
|
a1047b4e08 | ||
|
|
1dd1d32020 | ||
|
|
ee60ec8480 | ||
|
|
b378311873 | ||
|
|
54504cd1d5 | ||
|
|
e1d0e8f04a | ||
|
|
af83257c76 | ||
|
|
b518e98b43 | ||
|
|
1c031015de | ||
|
|
9c7f5283cf | ||
|
|
c03224fcd6 | ||
|
|
fef8cca2c4 | ||
|
|
c3a1885987 | ||
|
|
82136d6e0d | ||
|
|
f9ff17a614 | ||
|
|
18edc9d3ed | ||
|
|
cd1792df41 | ||
|
|
040c32a6d6 | ||
|
|
41910b01ec | ||
|
|
45f1ec4941 | ||
|
|
6efd2a362e | ||
|
|
30be0a5909 | ||
|
|
da4dcbce57 | ||
|
|
a44f3c763a | ||
|
|
276a8287cb | ||
|
|
ce23dccf03 | ||
|
|
5522adfd9e | ||
|
|
f99e5b59b8 | ||
|
|
7310c84129 | ||
|
|
b9d2e88e64 | ||
|
|
001d633750 | ||
|
|
d42c334b9a | ||
|
|
59ebbf6f8d | ||
|
|
948aa5c54b |
No files matched your search
@@ -43,6 +43,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
|
||||
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
|
||||
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
|
||||
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
|
||||
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
|
||||
|
||||
@@ -22,7 +22,7 @@ seule image.
|
||||
│ ▲ modèles .gguf │
|
||||
│ chromium (Playwright) — captures de pages web │
|
||||
│ /data (config, bbolt, presets, mémoire, │
|
||||
│ workspace + captures, modèles installés) │
|
||||
│ workspace par discussion, modèles) │
|
||||
│ /models (GGUF déposés à la main) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -122,8 +122,8 @@ et modèles compris.
|
||||
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
|
||||
| `/data/models/` | modèles téléchargés depuis l'interface |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) |
|
||||
| `/data/workspace/` | dossier de travail de l'agent |
|
||||
| `/data/workspace/captures/<id>/` | captures d'écran, un dossier par discussion |
|
||||
| `/data/workspace/` | racine du dossier de travail de l'agent |
|
||||
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
|
||||
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
|
||||
| `/models` | GGUF déposés à la main depuis l'hôte (volume séparé, `LOKI_MODEL_DIRS`) |
|
||||
|
||||
@@ -154,16 +154,38 @@ Héritées d'AJEAN :
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python.
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **API OpenAI-compatible** exposable (`network on`, protégée par clé).
|
||||
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
|
||||
fork la sert autrement que l'amont).
|
||||
|
||||
Ajoutées par ce fork :
|
||||
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
catégorie. Choisir une entrée **n'installe rien** : ça remplit le formulaire
|
||||
d'ajout, tu relis la commande — qui s'exécutera sur cette machine — puis tu
|
||||
enregistres. Le catalogue est un JSON **embarqué dans le binaire**
|
||||
(`internal/loki/mcp_catalog.json`), donc aucun appel à un annuaire distant :
|
||||
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
|
||||
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
|
||||
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
|
||||
- **Intensité du raisonnement** : en plus de l'interrupteur *Raisonnement*,
|
||||
l'éditeur de preset expose un niveau — auto / aucune / basse / moyenne / haute
|
||||
— envoyé à `llama-server` comme `reasoning_effort`. Réglage **par preset**,
|
||||
donc de fait par modèle. `none` coupe le raisonnement ; les autres valeurs sont
|
||||
passées au gabarit jinja du modèle, ce qui ne change le comportement que des
|
||||
modèles qui les lisent (gpt-oss et apparentés) — ailleurs c'est ignoré sans
|
||||
erreur, et le sous-titre du réglage le dit plutôt que de promettre un effet.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. Supprimer une discussion
|
||||
efface aussi ses captures d'écran (`workspace/captures/<id>/`), pour que le
|
||||
disque ne se remplisse pas en silence.
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
|
||||
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
|
||||
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
|
||||
le disque ne se remplisse pas en silence.
|
||||
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
|
||||
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
|
||||
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
|
||||
@@ -174,12 +196,43 @@ Ajoutées par ce fork :
|
||||
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
|
||||
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
|
||||
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
|
||||
- **Panneau Fichiers** (bouton dossier du pied de carte) : ce que l'agent a
|
||||
écrit dans son dossier de travail, avec navigation dans les sous-dossiers,
|
||||
téléchargement et suppression. Un dossier affiche la taille de **tout** son
|
||||
contenu — c'est ce qu'on libère en le supprimant. Le pied donne l'occupation
|
||||
disque totale de l'agent. Les chemins sont bornés au dossier de travail, liens
|
||||
symboliques résolus des deux côtés : rien du reste du disque n'est atteignable.
|
||||
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
|
||||
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
|
||||
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
|
||||
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
|
||||
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
|
||||
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
|
||||
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
|
||||
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
|
||||
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
|
||||
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
|
||||
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
|
||||
aucune requête vers un service de polices. Deux variantes : claire par défaut,
|
||||
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
|
||||
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
|
||||
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
|
||||
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
|
||||
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
|
||||
détectée est celle du bridge Docker).
|
||||
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
|
||||
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
|
||||
l'interface le dit en rouge.
|
||||
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
|
||||
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
|
||||
affichée suit celle du navigateur.
|
||||
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
|
||||
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
|
||||
- L'ancienne exposition publique via le relais de l'amont
|
||||
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
|
||||
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
|
||||
pouvait donc qu'échouer.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
||||
@@ -206,12 +259,13 @@ Retirées par ce fork :
|
||||
|---|---|---|
|
||||
| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` |
|
||||
| Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | image officielle llama.cpp (`server-cuda`), précompilée |
|
||||
| Panneau « Moteur » | propose d'installer/compiler | annonce le moteur fourni par l'image, sans proposer d'install |
|
||||
| Panneau « Moteur » | propose d'installer/compiler | affiche la version qui tourne et la met à jour en un clic (sans rebuild) |
|
||||
| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) |
|
||||
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
|
||||
| Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié |
|
||||
| Historique de tchat | conversation unique | discussions multiples, titrées et persistées |
|
||||
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
|
||||
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
|
||||
|
||||
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||
@@ -238,6 +292,44 @@ docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b
|
||||
Aucune compilation de llama.cpp n'a lieu : le moteur est maintenu et
|
||||
précompilé par l'équipe amont (toutes architectures GPU courantes).
|
||||
|
||||
## Mettre à jour llama.cpp sans reconstruire l'image
|
||||
|
||||
llama.cpp publie plusieurs versions par jour ; l'image de Loki, elle, ne se
|
||||
reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à la date
|
||||
du dernier build, et le rattraper imposait un rebuild complet (2,6 Go) pour un
|
||||
composant de 170 Mo.
|
||||
|
||||
**Réglages → Moteur** affiche désormais la version de llama.cpp qui tourne
|
||||
(`b10450`, avec son commit) et deux boutons :
|
||||
|
||||
- **vérifier la version** — interroge le registre et dit s'il existe plus récent ;
|
||||
- **mettre à jour le moteur** — télécharge `llama-server` et ses bibliothèques
|
||||
depuis l'image officielle, `ghcr.io/ggml-org/llama.cpp:server-cuda`.
|
||||
|
||||
Ce qui est téléchargé n'est **pas l'image** : un manifeste OCI liste ses couches,
|
||||
et seules celles qui portent `/app` sont récupérées (~170 Mo). Le runtime CUDA
|
||||
(2 Go) et la base système sont déjà dans l'image de Loki. Le moteur atterrit dans
|
||||
`/data/engine/<version>/`, donc sur le volume de données : il survit à un
|
||||
`docker compose pull`.
|
||||
|
||||
La variante est déduite du moteur en place (CUDA, Vulkan, SYCL, MUSA ou CPU) :
|
||||
une installation Vulkan ne se verra jamais proposer une image CUDA.
|
||||
|
||||
**Le garde-fou.** La mise à jour apporte llama.cpp, pas le runtime CUDA, qui
|
||||
reste celui de l'image. Un llama.cpp compilé pour un CUDA plus récent ne
|
||||
chargerait pas son backend GPU ici — et le symptôme serait silencieux : tout
|
||||
fonctionne, mais sur le processeur. Le nouveau moteur est donc lancé à blanc
|
||||
avant toute bascule ; s'il ne démarre pas, ou s'il ne voit plus aucune carte
|
||||
alors que le moteur courant en voyait, la mise à jour est refusée et le moteur
|
||||
courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intact :
|
||||
**revenir au moteur de l'image** y ramène en un clic, sans réseau.
|
||||
|
||||
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
|
||||
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
|
||||
|
||||
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
|
||||
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — © les contributeurs d'AJEAN (« Jean contributors ») pour le code amont,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -12,10 +12,41 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// readAPIKeyErr renvoie la clé Bearer des complétions en distinguant « aucune
|
||||
// clé » d'une LECTURE RATÉE — même nuance que readWebKeyErr, et pour la même
|
||||
// raison : sans clé, l'endpoint est OUVERT. Confondre les deux, c'est ouvrir
|
||||
// l'accès aux complétions parce que la base était momentanément verrouillée par
|
||||
// une commande CLI. requireCompletionKey refuse donc plutôt que d'ouvrir.
|
||||
func readAPIKeyErr() (string, error) {
|
||||
b, err := getBytesErr(bkState, "api_key")
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est
|
||||
// définie. Elle est rangée hors de la configuration pour survivre aux
|
||||
// changements de preset, qui remplacent la configuration en bloc.
|
||||
func readAPIKey() string { return getStr(bkState, "api_key") }
|
||||
// définie (ou illisible). Elle est rangée hors de la configuration pour survivre
|
||||
// aux changements de preset, qui remplacent la configuration en bloc. Réservé à
|
||||
// l'affichage et aux appels internes ; toute décision d'accès passe par
|
||||
// effectiveAPIKeyErr.
|
||||
func readAPIKey() string { k, _ := readAPIKeyErr(); return k }
|
||||
|
||||
// effectiveAPIKeyErr renvoie la clé que llama-server exige RÉELLEMENT : celle
|
||||
// rangée en base, sinon la clé résiduelle de config.env (repli rétro-compatible,
|
||||
// cf. backend_serve.go).
|
||||
//
|
||||
// Une seule source pour les deux côtés du relais. Quand la garde de Loki ne
|
||||
// regardait que la clé en base, un API_KEY oublié dans config.env donnait un
|
||||
// endpoint « ouvert » côté Loki et un 401 côté moteur — le client voyait un
|
||||
// refus sans jamais comprendre quelle clé fournir.
|
||||
func effectiveAPIKeyErr() (string, error) {
|
||||
k, err := readAPIKeyErr()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if k != "" {
|
||||
return k, nil
|
||||
}
|
||||
return strings.TrimSpace(ReadConfig()["API_KEY"]), nil
|
||||
}
|
||||
|
||||
// authHeader sets the Authorization: Bearer header on req when an API key is
|
||||
// configured, so Loki's own internal calls (chat/web/bench/test) authenticate
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
// backend_engine_oci.go — mise à jour du moteur llama.cpp SANS reconstruire
|
||||
// l'image et sans compiler : on va chercher le moteur là où l'équipe llama.cpp
|
||||
// le publie déjà tout compilé — dans son image de conteneur officielle — et on
|
||||
// en extrait le seul dossier qui nous intéresse, /app.
|
||||
//
|
||||
// Pourquoi ce détour plutôt que les releases GitHub (backend_prebuilt.go) :
|
||||
// llama.cpp ne publie AUCUN binaire CUDA pour Linux dans ses releases. La seule
|
||||
// distribution CUDA/Linux officielle et précompilée, ce sont ces images
|
||||
// (ghcr.io/ggml-org/llama.cpp:server-cuda-b#####). C'est aussi exactement ce
|
||||
// dont l'image de Loki hérite : mettre à jour par ce chemin, c'est obtenir le
|
||||
// même moteur qu'une reconstruction complète de l'image, en ~170 Mo au lieu de
|
||||
// 2,6 Go et sans passer par un rebuild.
|
||||
//
|
||||
// Ce qu'on télécharge : PAS l'image. Un manifeste OCI liste ses couches ; le
|
||||
// runtime CUDA (2 Go) et les paquets système sont déjà présents dans l'image de
|
||||
// Loki, donc inutiles. Seules les couches du haut portent /app (llama-server et
|
||||
// ses .so, ~170 Mo). On les prend en partant du sommet et on s'arrête dès qu'on
|
||||
// tient un moteur complet.
|
||||
//
|
||||
// Ce que ça NE remplace PAS : le runtime CUDA, lui, vient de l'image. Un
|
||||
// llama.cpp compilé pour un CUDA majeur plus récent que celui de l'image ne
|
||||
// démarrera pas — d'où le test à blanc (--list-devices) avant toute bascule, et
|
||||
// la possibilité de revenir au moteur d'origine en un clic.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ociBase est une variable et non une constante pour que les tests puissent la
|
||||
// pointer sur un faux registre : tout ce qui suit se joue dans le dialogue avec
|
||||
// le registre, et le vérifier en frappant ghcr.io rendrait la CI dépendante du
|
||||
// réseau.
|
||||
// LOKI_OCI_REGISTRY le remplace : c'est la porte de sortie pour un réseau qui
|
||||
// n'atteint pas ghcr.io directement — miroir d'entreprise, cache local — sans
|
||||
// quoi la mise à jour du moteur y serait tout simplement impossible.
|
||||
var ociBase = "https://ghcr.io"
|
||||
|
||||
func init() {
|
||||
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("LOKI_OCI_REGISTRY")), "/"); v != "" {
|
||||
if !strings.Contains(v, "://") {
|
||||
v = "https://" + v
|
||||
}
|
||||
ociBase = v
|
||||
}
|
||||
}
|
||||
|
||||
// ociHost : l'hôte seul, tel que l'attend le paramètre `service` du jeton.
|
||||
func ociHost() string {
|
||||
h := strings.TrimPrefix(strings.TrimPrefix(ociBase, "https://"), "http://")
|
||||
return strings.TrimSuffix(h, "/")
|
||||
}
|
||||
|
||||
const (
|
||||
ociEngineRepo = "ggml-org/llama.cpp"
|
||||
|
||||
// Au-delà de cette taille, une couche n'est plus une couche « applicative » :
|
||||
// c'est le runtime CUDA (2 Go) ou une base système, qu'on ne veut à aucun
|
||||
// prix télécharger. On descend les couches du sommet vers la base ; croiser
|
||||
// une couche de cette taille signifie qu'on a dépassé /app.
|
||||
ociLayerCap = 600 << 20
|
||||
)
|
||||
|
||||
// ociLayer : une couche du manifeste (on n'a besoin que de ça).
|
||||
type ociLayer struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ociDoc couvre à la fois un index multi-architecture et un manifeste d'image :
|
||||
// les deux arrivent sur la même URL, distingués par le champ qui est rempli.
|
||||
type ociDoc struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Manifests []struct {
|
||||
Digest string `json:"digest"`
|
||||
Platform struct {
|
||||
Architecture string `json:"architecture"`
|
||||
OS string `json:"os"`
|
||||
} `json:"platform"`
|
||||
} `json:"manifests"`
|
||||
Layers []ociLayer `json:"layers"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}
|
||||
|
||||
// ociAccept : tous les types de manifeste qu'on sait lire. Sans cet en-tête, le
|
||||
// registre répond en schéma 1 (obsolète) et la lecture échoue sans expliquer.
|
||||
const ociAccept = "application/vnd.oci.image.index.v1+json," +
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json," +
|
||||
"application/vnd.oci.image.manifest.v1+json," +
|
||||
"application/vnd.docker.distribution.manifest.v2+json"
|
||||
|
||||
// ociToken obtient un jeton de lecture ANONYME. Les images publiques de ghcr.io
|
||||
// sont lisibles sans compte, mais le registre exige quand même un jeton porteur.
|
||||
func ociToken(repo string) (string, error) {
|
||||
u := fmt.Sprintf("%s/token?scope=repository:%s:pull&service=%s", ociBase, repo, ociHost())
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(u)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("registre %s injoignable : %w", ociHost(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("%s/token : HTTP %d", ociHost(), resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Token == "" {
|
||||
return "", fmt.Errorf("jeton de lecture illisible")
|
||||
}
|
||||
return out.Token, nil
|
||||
}
|
||||
|
||||
// ociFetch fait un GET authentifié sur le registre.
|
||||
func ociFetch(url, token, accept string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
return (&http.Client{Timeout: 0}).Do(req)
|
||||
}
|
||||
|
||||
// ociArch traduit GOARCH vers le nom de plateforme OCI.
|
||||
func ociArch() string {
|
||||
if runtime.GOARCH == "arm64" {
|
||||
return "arm64"
|
||||
}
|
||||
return "amd64"
|
||||
}
|
||||
|
||||
// ociResolve lit le manifeste d'un tag et renvoie les couches de l'image pour
|
||||
// CETTE architecture, plus le numéro de build annoncé par l'image
|
||||
// (org.opencontainers.image.version, ex. « b10450 »).
|
||||
func ociResolve(repo, tag string) (layers []ociLayer, version string, err error) {
|
||||
token, err := ociToken(repo)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
get := func(ref string) (*ociDoc, error) {
|
||||
resp, err := ociFetch(fmt.Sprintf("%s/v2/%s/manifests/%s", ociBase, repo, ref), token, ociAccept)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("version inconnue dans le registre : %s", ref)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("manifeste %s : HTTP %d", ref, resp.StatusCode)
|
||||
}
|
||||
var d ociDoc
|
||||
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
|
||||
return nil, fmt.Errorf("manifeste %s illisible : %w", ref, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
doc, err := get(tag)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
version = doc.Annotations["org.opencontainers.image.version"]
|
||||
if len(doc.Manifests) > 0 {
|
||||
want := ociArch()
|
||||
digest := ""
|
||||
for _, m := range doc.Manifests {
|
||||
if m.Platform.OS == "linux" && m.Platform.Architecture == want {
|
||||
digest = m.Digest
|
||||
break
|
||||
}
|
||||
}
|
||||
if digest == "" {
|
||||
return nil, "", fmt.Errorf("aucune image linux/%s pour %s", want, tag)
|
||||
}
|
||||
if doc, err = get(digest); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if v := doc.Annotations["org.opencontainers.image.version"]; v != "" {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
if len(doc.Layers) == 0 {
|
||||
return nil, "", fmt.Errorf("manifeste %s sans couche", tag)
|
||||
}
|
||||
return doc.Layers, version, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variante, version et emplacement du moteur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCISupported : ce mécanisme n'a de sens que là où ces images tournent.
|
||||
// Les images llama.cpp sont linux/amd64 et linux/arm64, rien d'autre.
|
||||
func engineOCISupported() bool {
|
||||
return runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
|
||||
}
|
||||
|
||||
// engineVariant déduit la variante d'image correspondant au moteur COURANT, en
|
||||
// regardant les backends ggml posés à côté du binaire. C'est la seule source
|
||||
// fiable : le conteneur ne sait pas de quelle image il a été bâti, et demander
|
||||
// à l'utilisateur de retenir « server-cuda » serait un piège à erreur.
|
||||
//
|
||||
// Renvoie un préfixe de tag : "server-cuda", "server-vulkan", "server-intel",
|
||||
// "server-musa", ou "server" (CPU) par défaut.
|
||||
func engineVariant() string {
|
||||
dir := ""
|
||||
if bin := currentEngineBin(); bin != "" {
|
||||
dir = filepath.Dir(bin)
|
||||
}
|
||||
if dir == "" {
|
||||
return "server"
|
||||
}
|
||||
has := func(prefix string) bool {
|
||||
m, _ := filepath.Glob(filepath.Join(dir, prefix+"*"))
|
||||
return len(m) > 0
|
||||
}
|
||||
switch {
|
||||
case has("libggml-cuda.so"):
|
||||
return "server-cuda"
|
||||
case has("libggml-musa.so"):
|
||||
return "server-musa"
|
||||
case has("libggml-sycl.so"):
|
||||
return "server-intel"
|
||||
case has("libggml-vulkan.so"):
|
||||
return "server-vulkan"
|
||||
}
|
||||
return "server"
|
||||
}
|
||||
|
||||
// currentEngineBin : le binaire réellement utilisé (BIN de la configuration,
|
||||
// avec le repli sur le moteur de l'image quand BIN n'est pas encore posé).
|
||||
func currentEngineBin() string {
|
||||
bin := prebuiltResolveBin(ReadConfig()["BIN"])
|
||||
if bin != "" && isFile(bin) {
|
||||
return bin
|
||||
}
|
||||
return providedEngineBin()
|
||||
}
|
||||
|
||||
// La bannière de version de llama.cpp a changé de forme plusieurs fois, et un
|
||||
// binaire ancien reste parfaitement utilisable : on lit les trois formes.
|
||||
//
|
||||
// version: 0.1.0-dev (build 10450, commit ece963f41) ← actuelle
|
||||
// version: 10450 (ece963f) ← précédente
|
||||
// build: 4589 (a1b2c3d) ← plus ancienne
|
||||
//
|
||||
// C'est le NUMÉRO DE BUILD qui compte : c'est lui qui figure dans les tags
|
||||
// d'image (server-cuda-b10450) et donc la seule chose comparable entre ce qui
|
||||
// tourne et ce qui est publié.
|
||||
var reEngineBuildLong = regexp.MustCompile(`build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)`)
|
||||
var reEngineBuildShort = regexp.MustCompile(`(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)`)
|
||||
|
||||
func parseEngineBuild(out string) (build int, commit string) {
|
||||
for _, re := range []*regexp.Regexp{reEngineBuildLong, reEngineBuildShort} {
|
||||
if m := re.FindStringSubmatch(out); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n, m[2]
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
|
||||
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
|
||||
// d'en installer un autre.
|
||||
func engineBuildOf(bin string) (int, string) {
|
||||
if bin == "" || !isFile(bin) {
|
||||
return 0, ""
|
||||
}
|
||||
cmd := hideCmd(exec.Command(bin, "--version"))
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
|
||||
return parseEngineBuild(string(out))
|
||||
}
|
||||
|
||||
// engineDir : racine des moteurs installés par ce mécanisme, sous LOKI_HOME —
|
||||
// c'est-à-dire sur le volume /data en conteneur, donc conservés d'un
|
||||
// redémarrage (et d'une mise à jour d'image) à l'autre.
|
||||
func engineDir() string { return filepath.Join(LokiHome(), "engine") }
|
||||
|
||||
// engineOwns dit si un chemin désigne un moteur installé ici.
|
||||
func engineOwns(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
norm := func(s string) string { return strings.ToLower(filepath.ToSlash(filepath.Clean(s))) }
|
||||
return strings.HasPrefix(norm(p), norm(engineDir())+"/")
|
||||
}
|
||||
|
||||
// engineInstalled liste les moteurs présents sous engineDir(), du plus ancien
|
||||
// au plus récent (ordre des numéros de build).
|
||||
func engineInstalled() []string {
|
||||
entries, err := os.ReadDir(engineDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var tags []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && isFile(filepath.Join(engineDir(), e.Name(), "llama-server")) {
|
||||
tags = append(tags, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Slice(tags, func(i, j int) bool { return engineTagBuild(tags[i]) < engineTagBuild(tags[j]) })
|
||||
return tags
|
||||
}
|
||||
|
||||
var reTagBuild = regexp.MustCompile(`-b(\d+)$`)
|
||||
|
||||
// engineTagBuild extrait le numéro de build d'un tag (« server-cuda-b10450 » →
|
||||
// 10450), 0 si le tag n'en porte pas (tag mouvant « server-cuda »).
|
||||
func engineTagBuild(tag string) int {
|
||||
m := reTagBuild.FindStringSubmatch(tag)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}
|
||||
|
||||
// engineTagOf : le tag d'un moteur téléchargé ici (« server-cuda-b10450 »), ""
|
||||
// pour tout autre chemin.
|
||||
func engineTagOf(bin string) string {
|
||||
if !engineOwns(bin) {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(filepath.Dir(bin))
|
||||
}
|
||||
|
||||
// engineBinFor : chemin du llama-server d'une version installée.
|
||||
func engineBinFor(tag string) string {
|
||||
p := filepath.Join(engineDir(), filepath.Base(tag), "llama-server")
|
||||
if isFile(p) {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enginePrune supprime les versions installées autres que celles à garder.
|
||||
// Chaque version pèse ~450 Mo une fois décompressée : en laisser traîner cinq
|
||||
// remplit le volume /data sans rien apporter.
|
||||
func enginePrune(keep map[string]bool, logf func(string)) {
|
||||
for _, tag := range engineInstalled() {
|
||||
if keep[tag] {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err == nil && logf != nil {
|
||||
logf("ancienne version supprimée : " + tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCIInstall télécharge le moteur du tag demandé et l'installe dans
|
||||
// engineDir()/<tag>. Renvoie le chemin du llama-server installé.
|
||||
//
|
||||
// Réentrant : une version déjà installée est renvoyée telle quelle, et une
|
||||
// installation interrompue laisse un dossier « .part » qui est repris de zéro.
|
||||
func engineOCIInstall(tag string, logf, phasef func(string)) (string, error) {
|
||||
tag = filepath.Base(strings.TrimSpace(tag)) // jamais de séparateur dans un nom de dossier
|
||||
if tag == "" || tag == "." {
|
||||
return "", fmt.Errorf("version du moteur non précisée")
|
||||
}
|
||||
if bin := engineBinFor(tag); bin != "" {
|
||||
logf("déjà installé : " + tag)
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
phasef("lecture du manifeste " + tag + "…")
|
||||
layers, version, err := ociResolve(ociEngineRepo, tag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if version != "" {
|
||||
logf(fmt.Sprintf("image %s:%s — llama.cpp %s, %d couches", ociEngineRepo, tag, version, len(layers)))
|
||||
}
|
||||
|
||||
dest := filepath.Join(engineDir(), tag)
|
||||
tmp := dest + ".part"
|
||||
if err := os.RemoveAll(tmp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(tmp, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmp) // no-op après le rename final
|
||||
|
||||
token, err := ociToken(ociEngineRepo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// On descend du SOMMET vers la base : /app est ajouté en dernier dans le
|
||||
// Dockerfile amont, et les couches du bas sont le runtime CUDA et le système,
|
||||
// déjà présents ici. Une couche du haut peut masquer un fichier d'une couche
|
||||
// plus basse : le premier extrait gagne, comme le ferait un runtime OCI.
|
||||
seen := map[string]bool{}
|
||||
var links []archiveLink
|
||||
for i := len(layers) - 1; i >= 0; i-- {
|
||||
l := layers[i]
|
||||
if l.Size > ociLayerCap {
|
||||
logf(fmt.Sprintf("couche %d ignorée (%d Mo) : c'est le runtime de l'image, pas le moteur", i, l.Size/1_000_000))
|
||||
break
|
||||
}
|
||||
part := filepath.Join(tmp, "layer.tgz")
|
||||
if l.Size > 4<<20 {
|
||||
phasef(fmt.Sprintf("téléchargement du moteur (%d Mo)…", l.Size/1_000_000))
|
||||
}
|
||||
if err := ociDownloadBlob(ociEngineRepo, l.Digest, token, part, l.Size, logf); err != nil {
|
||||
return "", fmt.Errorf("téléchargement de la couche %d : %w", i, err)
|
||||
}
|
||||
n, err := extractAppLayer(part, tmp, seen, &links)
|
||||
_ = os.Remove(part)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extraction de la couche %d : %w", i, err)
|
||||
}
|
||||
if n > 0 {
|
||||
logf(fmt.Sprintf("couche %d : %d fichiers du moteur", i, n))
|
||||
}
|
||||
if engineSetComplete(seen) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := applyLinks(links); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !seen["llama-server"] {
|
||||
return "", fmt.Errorf("llama-server absent de l'image %s — variante inattendue ?", tag)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bin := filepath.Join(dest, "llama-server")
|
||||
if err := os.Chmod(bin, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logf("moteur installé : " + bin)
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
// engineSetComplete : a-t-on de quoi lancer le moteur ? Le binaire seul ne
|
||||
// suffit pas — depuis que llama.cpp éclate son code en bibliothèques, l'exécutable
|
||||
// tient dans une couche et ses .so dans la précédente. S'arrêter au binaire
|
||||
// donnait un moteur qui meurt sur « libllama.so: cannot open shared object ».
|
||||
func engineSetComplete(seen map[string]bool) bool {
|
||||
if !seen["llama-server"] {
|
||||
return false
|
||||
}
|
||||
base, llama := false, false
|
||||
for name := range seen {
|
||||
switch {
|
||||
case strings.HasPrefix(name, "libggml-base.so"):
|
||||
base = true
|
||||
case strings.HasPrefix(name, "libllama.so"):
|
||||
llama = true
|
||||
}
|
||||
}
|
||||
return base && llama
|
||||
}
|
||||
|
||||
// ociDownloadBlob télécharge une couche vers dest, avec la progression du
|
||||
// téléchargement dans le journal du job.
|
||||
func ociDownloadBlob(repo, digest, token, dest string, size int64, logf func(string)) error {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/%s/blobs/%s", ociBase, repo, digest), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return downloadRequest(req, dest, size, logf)
|
||||
}
|
||||
|
||||
// extractAppLayer extrait les entrées « app/… » d'une couche (tar.gz) à PLAT
|
||||
// dans dir, en ignorant celles déjà vues (une couche supérieure fait foi) et
|
||||
// les marqueurs de suppression OCI (.wh.*). Renvoie le nombre de fichiers
|
||||
// extraits. Les liens sont accumulés dans links : leur cible n'est pas
|
||||
// forcément déjà extraite au moment où on croise l'entrée.
|
||||
func extractAppLayer(archive, dir string, seen map[string]bool, links *[]archiveLink) (int, error) {
|
||||
f, err := os.Open(archive)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
count := 0
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return count, nil
|
||||
}
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
name, ok := appEntryName(h.Name)
|
||||
if !ok || seen[name] {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeReg:
|
||||
w, err := os.Create(p)
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
if _, err := io.Copy(w, tr); err != nil {
|
||||
w.Close()
|
||||
return count, err
|
||||
}
|
||||
w.Close()
|
||||
_ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
|
||||
seen[name] = true
|
||||
count++
|
||||
case tar.TypeSymlink, tar.TypeLink:
|
||||
// Indispensable : les .so sont livrés sous leur nom versionné
|
||||
// (libllama.so.0.1.0) plus deux liens (libllama.so.0, libllama.so) —
|
||||
// c'est le nom court que l'éditeur de liens cherche au lancement.
|
||||
target, ok := appEntryName(h.Linkname)
|
||||
if !ok {
|
||||
target = filepath.Base(h.Linkname) // lien relatif au sein de /app
|
||||
}
|
||||
*links = append(*links, archiveLink{path: p, target: target})
|
||||
seen[name] = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// appEntryName ramène « app/libllama.so » à « libllama.so ». Renvoie ok=false
|
||||
// pour tout ce qui n'est pas un fichier direct de /app : autres dossiers,
|
||||
// sous-dossiers, et marqueurs de suppression OCI.
|
||||
func appEntryName(raw string) (string, bool) {
|
||||
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
|
||||
rest, ok := strings.CutPrefix(p, "app/")
|
||||
if !ok || rest == "" || strings.Contains(rest, "/") {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(rest, ".wh.") {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vérification et bascule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCILatest renvoie le dernier build publié pour la variante (numéro et
|
||||
// tag figé correspondant). On lit le tag MOUVANT (« server-cuda ») et son
|
||||
// annotation de version plutôt que de lister les 1000 tags du dépôt : une seule
|
||||
// requête, et c'est l'amont qui dit ce qu'est « la dernière ».
|
||||
func engineOCILatest(variant string) (build int, tag string, err error) {
|
||||
_, version, err := ociResolve(ociEngineRepo, variant)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if version == "" {
|
||||
// Pas d'annotation : on retombe sur le tag mouvant lui-même, qui pointe
|
||||
// bien sur la dernière image — on ne saura juste pas dire son numéro.
|
||||
return 0, variant, nil
|
||||
}
|
||||
// L'annotation vaut « b10450 » : le tag figé est « server-cuda-b10450 ».
|
||||
n, _ := strconv.Atoi(strings.TrimPrefix(version, "b"))
|
||||
return n, variant + "-" + version, nil
|
||||
}
|
||||
|
||||
// engineSmokeTest lance à blanc le moteur fraîchement installé — c'est là que
|
||||
// se joue le seul vrai risque de ce mécanisme. La mise à jour apporte llama.cpp,
|
||||
// PAS le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
|
||||
// CUDA plus récent ne chargera pas son backend GPU ici.
|
||||
//
|
||||
// Deux échecs distincts, et le second est le sournois :
|
||||
//
|
||||
// 1. le binaire ne démarre pas du tout (bibliothèque manquante) — bruyant ;
|
||||
// 2. il démarre mais ne voit plus AUCUNE carte, alors que le moteur courant en
|
||||
// voyait. Rien ne casse : le modèle se charge, tourne sur le processeur, et
|
||||
// on ne s'en aperçoit qu'au débit. C'est le pire résultat possible, donc on
|
||||
// le traite comme un échec de mise à jour.
|
||||
//
|
||||
// Ne voir aucune carte n'est un échec que si le moteur courant en voyait :
|
||||
// sur une machine sans GPU, « aucune carte » est le résultat normal.
|
||||
func engineSmokeTest(bin, ancien string) error {
|
||||
devs, err := engineListDevices(bin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(devs) > 0 {
|
||||
return nil
|
||||
}
|
||||
if ancien == "" || ancien == bin {
|
||||
return nil
|
||||
}
|
||||
avant, err := engineListDevices(ancien)
|
||||
if err != nil || len(avant) == 0 {
|
||||
return nil // le moteur courant n'en voyait pas plus : rien à reprocher au nouveau
|
||||
}
|
||||
return fmt.Errorf("le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit %d — "+
|
||||
"il tournerait sur le processeur, beaucoup plus lentement", len(avant))
|
||||
}
|
||||
|
||||
// engineListDevices lance `llama-server --list-devices` sur un binaire donné.
|
||||
func engineListDevices(bin string) ([]map[string]any, error) {
|
||||
cmd := hideCmd(exec.Command(bin, "--list-devices"))
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("le moteur ne démarre pas ici : %v\n%s", err, tailLines(string(out), 12))
|
||||
}
|
||||
return parseListDevices(string(out)), nil
|
||||
}
|
||||
|
||||
// tailLines garde les n dernières lignes non vides d'une sortie.
|
||||
func tailLines(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package loki
|
||||
|
||||
// Vérification contre le VRAI registre. Ignoré par défaut (la CI ne doit pas
|
||||
// dépendre du réseau ni télécharger 170 Mo) :
|
||||
//
|
||||
// LOKI_NET_TEST=1 go test ./internal/loki -run TestMoteurReelDepuisGhcr -v
|
||||
//
|
||||
// Ce test existe parce que le faux registre ne peut pas garantir une chose : que
|
||||
// la DISPOSITION de l'image amont est toujours celle qu'on suppose. Si llama.cpp
|
||||
// range un jour son moteur autrement — sous-dossier, couche gigantesque, autre
|
||||
// jeu de bibliothèques — c'est ici qu'on le verra, et pas chez l'utilisateur.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMoteurReelDepuisGhcr(t *testing.T) {
|
||||
if os.Getenv("LOKI_NET_TEST") == "" {
|
||||
t.Skip("test réseau — LOKI_NET_TEST=1 pour l'exécuter")
|
||||
}
|
||||
if os.Getenv("LOKI_HOME") == "" {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
build, tag, err := engineOCILatest("server-cuda")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("dernier build publié : %d → %s", build, tag)
|
||||
if build == 0 {
|
||||
t.Error("numéro de build absent de l'annotation de l'image")
|
||||
}
|
||||
|
||||
bin, err := engineOCIInstall(tag, func(s string) { t.Log(s) }, func(s string) { t.Log("▶ " + s) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(bin)
|
||||
// Ce que l'éditeur de liens réclamera au lancement. Un seul manquant et le
|
||||
// moteur meurt sur « cannot open shared object file ».
|
||||
for _, n := range []string{"libllama.so", "libggml.so", "libggml-base.so", "libggml-cuda.so", "libmtmd.so"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
|
||||
t.Errorf("%s : %v", n, err)
|
||||
}
|
||||
}
|
||||
// Et le binaire doit savoir dire sa version : c'est ce qui pilote la
|
||||
// comparaison « installé vs publié » de tout le panneau.
|
||||
if got, commit := engineBuildOf(bin); got != build {
|
||||
t.Errorf("le moteur installé annonce le build %d (%s), attendu %d", got, commit, build)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package loki
|
||||
|
||||
// Mise à jour du moteur depuis l'image officielle llama.cpp.
|
||||
//
|
||||
// Ce qui se joue ici n'est pas « est-ce qu'on télécharge » mais « est-ce qu'on
|
||||
// télécharge le MINIMUM et est-ce que ce qu'on pose est LANÇABLE ». Deux pièges
|
||||
// concrets, tous deux invisibles sans test :
|
||||
//
|
||||
// - l'image officielle range llama-server dans une couche et ses .so dans la
|
||||
// précédente ; s'arrêter au binaire donne un moteur qui meurt sur
|
||||
// « libllama.so: cannot open shared object file » ;
|
||||
// - la couche du dessous est le runtime CUDA (2 Go). La descendre par erreur,
|
||||
// c'est transformer une mise à jour de 170 Mo en un téléchargement de 2 Go.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Faux registre OCI ------------------------------------------------------
|
||||
|
||||
// fauxCouche fabrique une couche tar.gz. Les entrées sont décrites par leur nom
|
||||
// complet dans l'archive ; une valeur préfixée par « -> » crée un lien.
|
||||
func fauxCouche(t *testing.T, entrees map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for nom, contenu := range entrees {
|
||||
if cible, ok := strings.CutPrefix(contenu, "-> "); ok {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeSymlink, Linkname: cible, Mode: 0o777}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeReg, Size: int64(len(contenu)), Mode: 0o755}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(contenu)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// fauxRegistre sert un index multi-architecture, le manifeste amd64 et les
|
||||
// blobs. `tailles` permet d'annoncer une taille différente de la taille réelle
|
||||
// (c'est ce que lit le garde-fou des grosses couches). Renvoie le compteur des
|
||||
// blobs réellement téléchargés.
|
||||
func fauxRegistre(t *testing.T, version string, couches [][]byte, tailles []int64) (servis map[string]int) {
|
||||
t.Helper()
|
||||
servis = map[string]int{}
|
||||
digests := make([]string, len(couches))
|
||||
blobs := map[string][]byte{}
|
||||
for i, c := range couches {
|
||||
sum := sha256.Sum256(c)
|
||||
digests[i] = "sha256:" + hex.EncodeToString(sum[:])
|
||||
blobs[digests[i]] = c
|
||||
}
|
||||
manifeste := map[string]any{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"layers": func() []map[string]any {
|
||||
var out []map[string]any
|
||||
for i := range couches {
|
||||
taille := int64(len(couches[i]))
|
||||
if tailles != nil && tailles[i] > 0 {
|
||||
taille = tailles[i]
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"digest": digests[i], "size": taille,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
}
|
||||
manBytes, _ := json.Marshal(manifeste)
|
||||
manSum := sha256.Sum256(manBytes)
|
||||
manDigest := "sha256:" + hex.EncodeToString(manSum[:])
|
||||
|
||||
index := map[string]any{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": []map[string]any{
|
||||
// L'architecture qui ne nous concerne pas vient EN PREMIER : choisir
|
||||
// « le premier manifeste » au lieu de filtrer sur la plateforme
|
||||
// installerait un moteur d'une autre architecture.
|
||||
{"digest": "sha256:mauvaise", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}},
|
||||
{"digest": manDigest, "platform": map[string]string{"os": "linux", "architecture": ociArch()}},
|
||||
},
|
||||
"annotations": map[string]string{"org.opencontainers.image.version": version},
|
||||
}
|
||||
indexBytes, _ := json.Marshal(index)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/token"):
|
||||
_, _ = w.Write([]byte(`{"token":"jeton-de-test"}`))
|
||||
case strings.Contains(r.URL.Path, "/manifests/"):
|
||||
if r.Header.Get("Authorization") != "Bearer jeton-de-test" {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
if ref == manDigest {
|
||||
_, _ = w.Write(manBytes)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(indexBytes)
|
||||
case strings.Contains(r.URL.Path, "/blobs/"):
|
||||
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
b, ok := blobs[ref]
|
||||
if !ok {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
servis[ref]++
|
||||
_, _ = w.Write(b)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ancien := ociBase
|
||||
ociBase = srv.URL
|
||||
t.Cleanup(func() { ociBase = ancien })
|
||||
return servis
|
||||
}
|
||||
|
||||
// --- Tests ------------------------------------------------------------------
|
||||
|
||||
// Le cas réel : trois couches utiles au-dessus d'un runtime de 2 Go. On doit
|
||||
// prendre le binaire ET les bibliothèques, résoudre les liens de version, et
|
||||
// ne JAMAIS descendre jusqu'au runtime.
|
||||
func TestInstallationMoteurDepuisImage(t *testing.T) {
|
||||
testHome(t)
|
||||
couches := [][]byte{
|
||||
fauxCouche(t, map[string]string{"usr/local/cuda/lib64/libcudart.so": "runtime CUDA"}), // 2 Go annoncés
|
||||
fauxCouche(t, map[string]string{
|
||||
"app/libggml-base.so": "-> libggml-base.so.0",
|
||||
"app/libggml-base.so.0": "-> libggml-base.so.0.20.0",
|
||||
"app/libggml-base.so.0.20.0": "binaire ELF",
|
||||
"app/libllama.so": "-> libllama.so.0",
|
||||
"app/libllama.so.0": "-> libllama.so.0.1.0",
|
||||
"app/libllama.so.0.1.0": "binaire ELF",
|
||||
"app/libggml-cuda.so": "binaire ELF",
|
||||
}),
|
||||
fauxCouche(t, map[string]string{"app/llama-server": "#!/bin/true\n", "app/llama": "#!/bin/true\n"}),
|
||||
fauxCouche(t, map[string]string{"etc/passwd": "sans rapport"}),
|
||||
}
|
||||
servis := fauxRegistre(t, "b10450", couches, []int64{2 << 30, 0, 0, 0})
|
||||
|
||||
bin, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if veut := filepath.Join(engineDir(), "server-cuda-b10450", "llama-server"); bin != veut {
|
||||
t.Errorf("binaire installé en %s, attendu %s", bin, veut)
|
||||
}
|
||||
|
||||
// La couche du runtime CUDA ne doit pas avoir été demandée : c'est tout
|
||||
// l'intérêt (170 Mo au lieu de 2,6 Go).
|
||||
sum := sha256.Sum256(couches[0])
|
||||
if n := servis["sha256:"+hex.EncodeToString(sum[:])]; n != 0 {
|
||||
t.Errorf("la couche du runtime CUDA a été téléchargée %d fois", n)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(bin)
|
||||
for _, nom := range []string{"llama-server", "llama", "libggml-cuda.so", "libggml-base.so.0.20.0", "libllama.so.0.1.0"} {
|
||||
if !isFile(filepath.Join(dir, nom)) {
|
||||
t.Errorf("%s manquant", nom)
|
||||
}
|
||||
}
|
||||
// Les noms courts sont ceux que l'éditeur de liens cherche au lancement :
|
||||
// sans eux le moteur ne démarre pas, alors que tout « a l'air » installé.
|
||||
for _, lien := range []string{"libggml-base.so", "libggml-base.so.0", "libllama.so", "libllama.so.0"} {
|
||||
p := filepath.Join(dir, lien)
|
||||
if _, err := os.Stat(p); err != nil { // Stat suit le lien : vérifie aussi qu'il ne pend pas
|
||||
t.Errorf("%s : %v", lien, err)
|
||||
}
|
||||
}
|
||||
// /app à plat, et rien d'autre que /app.
|
||||
if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) {
|
||||
t.Error("des fichiers hors /app ont été extraits")
|
||||
}
|
||||
if st, err := os.Stat(bin); err != nil || st.Mode()&0o100 == 0 {
|
||||
t.Errorf("llama-server non exécutable : %v", err)
|
||||
}
|
||||
|
||||
// Réinstaller la même version ne redemande RIEN au registre : le job de mise
|
||||
// à jour repasse par là à chaque vérification.
|
||||
total := func() int {
|
||||
n := 0
|
||||
for _, v := range servis {
|
||||
n += v
|
||||
}
|
||||
return n
|
||||
}
|
||||
avant := total()
|
||||
if _, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total() != avant {
|
||||
t.Errorf("une version déjà installée a été re-téléchargée (%d → %d couches)", avant, total())
|
||||
}
|
||||
|
||||
// Et la version se retrouve dans la liste des moteurs installés.
|
||||
if got := engineInstalled(); len(got) != 1 || got[0] != "server-cuda-b10450" {
|
||||
t.Errorf("versions installées = %v", got)
|
||||
}
|
||||
if engineTagBuild("server-cuda-b10450") != 10450 {
|
||||
t.Error("numéro de build mal extrait du tag")
|
||||
}
|
||||
if !engineOwns(bin) {
|
||||
t.Error("le binaire installé n'est pas reconnu comme géré par Loki")
|
||||
}
|
||||
}
|
||||
|
||||
// Une variante dont l'image ne contient pas de llama-server (mauvais tag, image
|
||||
// « full » sans serveur) doit échouer CLAIREMENT, et ne rien laisser derrière
|
||||
// elle qui ressemblerait à un moteur installé.
|
||||
func TestInstallationRefuseUneImageSansMoteur(t *testing.T) {
|
||||
testHome(t)
|
||||
fauxRegistre(t, "b10450", [][]byte{
|
||||
fauxCouche(t, map[string]string{"app/README": "pas de moteur ici"}),
|
||||
}, nil)
|
||||
|
||||
if _, err := engineOCIInstall("server-b1", func(string) {}, func(string) {}); err == nil {
|
||||
t.Fatal("installation acceptée sans llama-server")
|
||||
}
|
||||
if got := engineInstalled(); len(got) != 0 {
|
||||
t.Errorf("versions installées après échec = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// engineOCILatest lit le numéro de build dans l'annotation de l'image, et en
|
||||
// déduit le tag figé — c'est lui qu'on installe, pour que « mis à jour vers »
|
||||
// désigne une version précise et reproductible plutôt qu'un tag mouvant.
|
||||
func TestDerniereVersionPubliee(t *testing.T) {
|
||||
fauxRegistre(t, "b10450", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
|
||||
build, tag, err := engineOCILatest("server-cuda")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if build != 10450 || tag != "server-cuda-b10450" {
|
||||
t.Errorf("build=%d tag=%q", build, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// La bannière du moteur est la seule source de vérité sur la version qui tourne
|
||||
// vraiment — le tag de l'image, lui, ment dès qu'on a basculé de moteur.
|
||||
func TestLectureVersionMoteur(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
nom, sortie string
|
||||
build int
|
||||
commit string
|
||||
}{
|
||||
// Relevée sur le binaire réel de l'image server-cuda-b10450.
|
||||
{"forme actuelle", "version: 0.1.0-dev (build 10450, commit ece963f41)\nbuilt with GNU 14.2.0 for Linux x86_64\n", 10450, "ece963f41"},
|
||||
{"forme précédente", "version: 10450 (ece963f)\nbuilt with cc\n", 10450, "ece963f"},
|
||||
{"forme ancienne", "build: 4589 (a1b2c3d)\n", 4589, "a1b2c3d"},
|
||||
{"bannière de démarrage", "ggml_cuda_init: found 1 device\nversion: 9001 (deadbee)\n", 9001, "deadbee"},
|
||||
{"rien d'exploitable", "usage: llama-server [options]\n", 0, ""},
|
||||
} {
|
||||
t.Run(c.nom, func(t *testing.T) {
|
||||
build, commit := parseEngineBuild(c.sortie)
|
||||
if build != c.build || commit != c.commit {
|
||||
t.Errorf("= (%d, %q), attendu (%d, %q)", build, commit, c.build, c.commit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Le filtre des entrées d'archive : on ne veut QUE les fichiers directs de /app.
|
||||
// Un sous-dossier ou un marqueur de suppression OCI recopié à plat produirait
|
||||
// des fichiers parasites à côté du binaire.
|
||||
func TestFiltreDesEntreesApp(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
entree, veut string
|
||||
ok bool
|
||||
}{
|
||||
{"app/llama-server", "llama-server", true},
|
||||
{"./app/libllama.so", "libllama.so", true},
|
||||
{"app/", "", false},
|
||||
{"app/tools/quantize", "", false},
|
||||
{"app/.wh.libggml-cuda.so", "", false},
|
||||
{"usr/local/cuda/lib64/libcudart.so", "", false},
|
||||
{"apparence/piege", "", false},
|
||||
} {
|
||||
got, ok := appEntryName(c.entree)
|
||||
if ok != c.ok || got != c.veut {
|
||||
t.Errorf("%q → (%q, %v), attendu (%q, %v)", c.entree, got, ok, c.veut, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le binaire seul ne fait pas un moteur : c'est la règle d'arrêt de la descente
|
||||
// des couches.
|
||||
func TestMoteurCompletExigeLesBibliotheques(t *testing.T) {
|
||||
vu := func(noms ...string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, n := range noms {
|
||||
m[n] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
if engineSetComplete(vu("llama-server")) {
|
||||
t.Error("binaire seul accepté comme moteur complet")
|
||||
}
|
||||
if engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0")) {
|
||||
t.Error("moteur sans libllama accepté")
|
||||
}
|
||||
if !engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0", "libllama.so.0.1.0")) {
|
||||
t.Error("moteur complet refusé")
|
||||
}
|
||||
}
|
||||
|
||||
// La variante se déduit des backends posés à côté du binaire : c'est ce qui
|
||||
// évite de proposer une image CUDA à une installation Vulkan (et l'inverse).
|
||||
func TestVarianteDeduiteDesBackends(t *testing.T) {
|
||||
for _, c := range []struct{ backend, veut string }{
|
||||
{"libggml-cuda.so", "server-cuda"},
|
||||
{"libggml-vulkan.so", "server-vulkan"},
|
||||
{"libggml-sycl.so", "server-intel"},
|
||||
{"libggml-musa.so", "server-musa"},
|
||||
{"", "server"},
|
||||
} {
|
||||
t.Run(c.veut, func(t *testing.T) {
|
||||
home := testHome(t)
|
||||
dir := filepath.Join(home, "faux-moteur")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(dir, "llama-server")
|
||||
if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.backend != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, c.backend), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := engineVariant(); got != c.veut {
|
||||
t.Errorf("variante = %q, attendu %q", got, c.veut)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Le ménage garde la version demandée et efface les autres : sans lui, chaque
|
||||
// mise à jour laisse ~450 Mo sur le volume de données.
|
||||
func TestMenageDesAnciennesVersions(t *testing.T) {
|
||||
testHome(t)
|
||||
for _, tag := range []string{"server-cuda-b1", "server-cuda-b2", "server-cuda-b3"} {
|
||||
d := filepath.Join(engineDir(), tag)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
enginePrune(map[string]bool{"server-cuda-b3": true}, nil)
|
||||
got := engineInstalled()
|
||||
if fmt.Sprint(got) != "[server-cuda-b3]" {
|
||||
t.Errorf("après ménage : %v", got)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,34 @@ func reasoningActive(v string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
|
||||
// "" (auto) n'y figure pas : c'est l'absence de réglage.
|
||||
var ReasoningEfforts = []string{"none", "low", "medium", "high"}
|
||||
|
||||
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
|
||||
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
|
||||
//
|
||||
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
|
||||
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
|
||||
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur —
|
||||
// c'est pourquoi il n'y a pas de repli à prévoir ici, contrairement à un backend
|
||||
// qui rejetterait la requête. En pratique seuls les modèles dont le gabarit
|
||||
// gère `reasoning_effort` (gpt-oss et apparentés) changent de comportement.
|
||||
func reasoningEffortValue(v string) string {
|
||||
switch s := strings.ToLower(strings.TrimSpace(v)); s {
|
||||
case "", "auto", "default":
|
||||
return "" // rien envoyé : le gabarit garde son comportement
|
||||
case "off", "false", "0", "no", "disable", "disabled", "none":
|
||||
return "none"
|
||||
case "low", "medium", "high":
|
||||
return s
|
||||
default:
|
||||
// Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse
|
||||
// passer au gabarit, qui l'ignorera s'il ne la connaît pas.
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
|
||||
// (manual override, with or without a leading '#') wins; otherwise it is
|
||||
// auto-detected from the MODEL= filename. Returns "" when unknown.
|
||||
|
||||
@@ -440,8 +440,18 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) {
|
||||
// downloadWithProgress télécharge url vers dest en journalisant la progression
|
||||
// par tranches de ~25 Mo.
|
||||
func downloadWithProgress(url, dest string, total int64, logf func(string)) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return downloadRequest(req, dest, total, logf)
|
||||
}
|
||||
|
||||
// downloadRequest est la même chose pour une requête déjà construite — un
|
||||
// registre OCI exige un en-tête d'autorisation, même en lecture anonyme.
|
||||
func downloadRequest(req *http.Request, dest string, total int64, logf func(string)) error {
|
||||
client := &http.Client{Timeout: 0}
|
||||
resp, err := client.Get(url)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -176,9 +176,7 @@ func cmdServe(args []string) error {
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
|
||||
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
|
||||
if k := readAPIKey(); k != "" {
|
||||
llmArgs = append(llmArgs, "--api-key", k)
|
||||
} else if k := cfg["API_KEY"]; k != "" {
|
||||
if k, _ := effectiveAPIKeyErr(); k != "" {
|
||||
llmArgs = append(llmArgs, "--api-key", k)
|
||||
}
|
||||
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
|
||||
|
||||
@@ -557,9 +557,11 @@ func (c *Conversation) Stop() {
|
||||
// le bump d'epoch réduit de toute façon au silence.
|
||||
func (c *Conversation) Reset() {
|
||||
c.Stop()
|
||||
// Vider la discussion efface aussi ses captures : les messages qui les
|
||||
// affichaient disparaissent, les fichiers n'auraient plus aucun lecteur.
|
||||
dropConvCaptures(convEnsureActive())
|
||||
// Vider la discussion efface aussi ses fichiers (dépôts, captures, ce que
|
||||
// l'agent y a écrit) : les messages qui les mentionnaient disparaissent, plus
|
||||
// rien ne les rattacherait à quoi que ce soit. L'UI le dit avant de demander
|
||||
// confirmation.
|
||||
dropConvFiles(convEnsureActive())
|
||||
c.mu.Lock()
|
||||
c.Messages = nil
|
||||
c.Log = nil
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package loki
|
||||
|
||||
// chat_convfiles.go — les fichiers rangés PAR DISCUSSION.
|
||||
//
|
||||
// Avant, tout ce qui passait par le chat — pièces jointes déposées, rapports
|
||||
// écrits par l'agent, captures de pages — atterrissait dans UN seul dossier de
|
||||
// travail partagé par toutes les discussions. Changer de discussion ne changeait
|
||||
// rien au panneau Fichiers (on revoyait les mêmes), et supprimer une discussion
|
||||
// laissait ses fichiers derrière elle — seules ses captures, déjà rangées par
|
||||
// identifiant, partaient avec.
|
||||
//
|
||||
// Désormais chaque discussion a son dossier :
|
||||
//
|
||||
// <workspace>/discussions/<id>/ ← dossier courant de l'agent
|
||||
// <workspace>/discussions/<id>/uploads/ ← pièces jointes de CETTE discussion
|
||||
// <workspace>/discussions/<id>/captures/ ← captures de CETTE discussion
|
||||
//
|
||||
// Supprimer une discussion (ou la vider) emporte tout le dossier, et le panneau
|
||||
// Fichiers n'ouvre que celui de la discussion active.
|
||||
//
|
||||
// La racine du dossier de travail reste la BORNE de sécurité : les liens écrits
|
||||
// dans les anciens messages (`uploads/x.pdf`, `captures/<id>/y.jpg`) pointent
|
||||
// encore vers elle et doivent continuer de marcher — c'est le rôle de
|
||||
// workspaceFile, qui cherche d'abord dans la discussion puis retombe sur les
|
||||
// emplacements d'avant.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
// convFilesRoot : le sous-dossier qui contient un dossier par discussion.
|
||||
// Nommé, plutôt que d'éparpiller les identifiants à la racine, pour que le
|
||||
// dossier de travail reste lisible quand on l'ouvre depuis l'hôte.
|
||||
convFilesRoot = "discussions"
|
||||
|
||||
// uploadsSub : les dépôts de l'utilisateur, à l'intérieur de la discussion.
|
||||
uploadsSub = "uploads"
|
||||
)
|
||||
|
||||
// safeConvID refuse tout identifiant qui ne soit pas un simple nom : c'est lui
|
||||
// qui devient un segment de chemin, et il arrive parfois du client (paramètre de
|
||||
// requête, ancien lien de message). Renvoie "" si l'identifiant est douteux.
|
||||
func safeConvID(id string) string {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range id {
|
||||
if r == '-' || r == '_' || unicode.IsDigit(r) || (r < unicode.MaxASCII && unicode.IsLetter(r)) {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// convDirFor renvoie le dossier (absolu) d'une discussion, sans le créer. Un
|
||||
// identifiant vide ou douteux retombe sur la racine du dossier de travail :
|
||||
// mieux vaut écrire au mauvais endroit que refuser d'écrire.
|
||||
func convDirFor(convID string) string {
|
||||
id := safeConvID(convID)
|
||||
if id == "" {
|
||||
return agentWorkspace()
|
||||
}
|
||||
return filepath.Join(agentWorkspace(), convFilesRoot, id)
|
||||
}
|
||||
|
||||
// convWorkspace est le dossier de travail COURANT : celui de la discussion
|
||||
// active, créé au besoin. C'est là que résolvent les chemins relatifs du modèle
|
||||
// (resolveAgentPath), que démarre son shell, et que se déposent les fichiers.
|
||||
//
|
||||
// Si la création échoue (disque plein, droits), on retombe sur la racine du
|
||||
// dossier de travail : l'agent doit continuer d'écrire quelque part.
|
||||
func convWorkspace() string {
|
||||
dir := convDirFor(convEnsureActive())
|
||||
if dir == agentWorkspace() {
|
||||
return dir
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return agentWorkspace()
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// dropConvFiles efface TOUT le dossier d'une discussion (dépôts, captures,
|
||||
// fichiers écrits par l'agent). Appelé quand la discussion est supprimée ou
|
||||
// vidée : plus un message ne les mentionne, personne ne les retrouverait.
|
||||
// Best-effort — un échec ne doit rien interrompre.
|
||||
func dropConvFiles(convID string) {
|
||||
dir := convDirFor(convID)
|
||||
if dir == agentWorkspace() {
|
||||
return // identifiant douteux : surtout pas la racine
|
||||
}
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
// relWithin dit si `abs` se trouve DANS `root` et, si oui, renvoie son chemin
|
||||
// relatif en séparateurs '/'.
|
||||
//
|
||||
// EvalSymlinks des deux côtés : sans ça, un lien posé dans le dossier passerait
|
||||
// le test de préfixe tout en pointant ailleurs sur le disque.
|
||||
func relWithin(root, abs string) (string, bool) {
|
||||
if root == "" {
|
||||
return "", false
|
||||
}
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = r
|
||||
}
|
||||
if a, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = a
|
||||
}
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.ToSlash(rel), true
|
||||
}
|
||||
|
||||
// workspaceFile résout un chemin RELATIF venu d'un client ou d'un message
|
||||
// (`uploads/rapport.pdf`, `captures/xxx.jpg`) en fichier du disque.
|
||||
//
|
||||
// Ordre de recherche :
|
||||
// 1. le dossier de la discussion active — le cas normal ;
|
||||
// 2. les emplacements d'AVANT le rangement par discussion, pour que les liens
|
||||
// des anciens messages continuent d'ouvrir leur fichier.
|
||||
//
|
||||
// ok=false signale une sortie du périmètre (« .. »), pas une absence : un
|
||||
// fichier introuvable revient avec le chemin attendu, pour que l'appelant
|
||||
// réponde « introuvable » et non « hors du dossier de travail ».
|
||||
func workspaceFile(rel string) (string, bool) {
|
||||
rel = strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"), "/")
|
||||
if rel == "" {
|
||||
return "", false
|
||||
}
|
||||
base := convWorkspace()
|
||||
p := filepath.Join(base, filepath.FromSlash(rel))
|
||||
if _, ok := relWithin(base, p); !ok {
|
||||
return "", false
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, true
|
||||
}
|
||||
if legacy, ok := legacyWorkspaceFile(rel); ok {
|
||||
return legacy, true
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// legacyWorkspaceFile retrouve un fichier désigné par un chemin d'avant le
|
||||
// rangement par discussion. Deux formes existaient :
|
||||
//
|
||||
// captures/<id>/x.jpg → discussions/<id>/captures/x.jpg (déplacé par la migration)
|
||||
// uploads/x.pdf → resté à la racine si aucune discussion ne le réclamait
|
||||
func legacyWorkspaceFile(rel string) (string, bool) {
|
||||
if parts := strings.SplitN(rel, "/", 3); len(parts) == 3 && parts[0] == captureDir && safeConvID(parts[1]) != "" {
|
||||
p := filepath.Join(convDirFor(parts[1]), captureDir, filepath.FromSlash(parts[2]))
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
p := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
|
||||
if _, ok := workspaceRel(p); !ok {
|
||||
return "", false
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// filesRoot donne la racine du panneau Fichiers pour un `scope` de requête :
|
||||
// la discussion active par défaut, la racine du dossier de travail pour
|
||||
// `scope=legacy` — celle où dorment les fichiers d'avant le rangement par
|
||||
// discussion, que la migration n'a pas su rattacher.
|
||||
func filesRoot(scope string) (root string, legacy bool) {
|
||||
if strings.TrimSpace(scope) == "legacy" {
|
||||
return agentWorkspace(), true
|
||||
}
|
||||
return convWorkspace(), false
|
||||
}
|
||||
|
||||
// legacyCount compte ce qui traîne à la racine du dossier de travail, hors
|
||||
// dossier des discussions. L'UI n'affiche le raccourci « hors discussion » que
|
||||
// si ce compte est non nul — il tombe à zéro une fois le ménage fait, et le
|
||||
// bouton disparaît de lui-même.
|
||||
func legacyCount() int {
|
||||
ents, err := os.ReadDir(agentWorkspace())
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, e := range ents {
|
||||
if e.Name() == convFilesRoot || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var convFilesMigrateOnce sync.Once
|
||||
|
||||
// migrateConvFiles range les fichiers des versions précédentes dans le dossier
|
||||
// de leur discussion. Une seule fois par process, au démarrage du serveur web.
|
||||
func migrateConvFiles() { convFilesMigrateOnce.Do(migrateConvFilesNow) }
|
||||
|
||||
func migrateConvFilesNow() {
|
||||
root := agentWorkspace()
|
||||
if root == "" {
|
||||
return
|
||||
}
|
||||
// 1. Les captures étaient DÉJÀ par discussion : captures/<id> → discussions/<id>/captures.
|
||||
// Un simple renommage, donc instantané quel que soit le nombre d'images.
|
||||
oldCaps := filepath.Join(root, captureDir)
|
||||
if ents, err := os.ReadDir(oldCaps); err == nil {
|
||||
for _, e := range ents {
|
||||
id := safeConvID(e.Name())
|
||||
if !e.IsDir() || id == "" {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(convDirFor(id), captureDir)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue // déjà rangé (migration interrompue puis reprise)
|
||||
}
|
||||
if os.MkdirAll(filepath.Dir(dst), 0o755) != nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Rename(filepath.Join(oldCaps, e.Name()), dst)
|
||||
}
|
||||
_ = os.Remove(oldCaps) // ne part que s'il est vide
|
||||
}
|
||||
// 2. Les dépôts, eux, étaient dans un pot commun. On rend chaque fichier à la
|
||||
// discussion qui le mentionne — l'historique garde le nom des pièces
|
||||
// jointes de chaque tour. Ce qui n'est réclamé par personne reste à la
|
||||
// racine : toujours téléchargeable (legacyWorkspaceFile) et visible via
|
||||
// « hors discussion », plutôt que rattaché au hasard.
|
||||
migrateLegacyUploads(filepath.Join(root, uploadsSub))
|
||||
}
|
||||
|
||||
func migrateLegacyUploads(src string) {
|
||||
ents, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
left := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() {
|
||||
left[e.Name()] = true
|
||||
}
|
||||
}
|
||||
for _, m := range convIndex() {
|
||||
if len(left) == 0 {
|
||||
break
|
||||
}
|
||||
for _, name := range convAttachNames(m.ID) {
|
||||
if !left[name] {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(convDirFor(m.ID), uploadsSub)
|
||||
if os.MkdirAll(dst, 0o755) != nil {
|
||||
continue
|
||||
}
|
||||
if os.Rename(filepath.Join(src, name), filepath.Join(dst, name)) == nil {
|
||||
delete(left, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = os.Remove(src) // ne part que s'il est vide
|
||||
}
|
||||
|
||||
// convAttachNames relit les pièces jointes annoncées dans le journal d'affichage
|
||||
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
|
||||
// relie un fichier déposé à sa discussion.
|
||||
func convAttachNames(id string) []string {
|
||||
b := getBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var st struct {
|
||||
Log []struct {
|
||||
Delta struct {
|
||||
Files []attachInfo `json:"files"`
|
||||
} `json:"delta"`
|
||||
} `json:"log"`
|
||||
}
|
||||
if json.Unmarshal(b, &st) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, ev := range st.Log {
|
||||
for _, f := range ev.Delta.Files {
|
||||
if n := safeUploadName(f.Name); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le cœur de la demande : deux discussions ne partagent pas leurs fichiers.
|
||||
// Avant, uploads/ était commun — on retrouvait les mêmes pièces jointes partout.
|
||||
func TestFichiersIsolesParDiscussion(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
a := convEnsureActive()
|
||||
dirA, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dirA, "note-a.txt"), "aaa")
|
||||
|
||||
b := convNew() // b devient active
|
||||
if a == b {
|
||||
t.Fatal("convNew n'a pas créé de nouvelle discussion")
|
||||
}
|
||||
dirB, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dirA == dirB {
|
||||
t.Fatalf("les deux discussions déposent au même endroit : %s", dirA)
|
||||
}
|
||||
writeFile(t, filepath.Join(dirB, "note-b.txt"), "bbb")
|
||||
|
||||
// La discussion ouverte ne voit QUE ses propres dépôts.
|
||||
names := listedNames(t, "uploads")
|
||||
if names["note-a.txt"] || !names["note-b.txt"] {
|
||||
t.Fatalf("la liste de la discussion b montre %v", names)
|
||||
}
|
||||
// Et le message ne peut pas joindre le fichier de l'autre discussion.
|
||||
if got := attachFiles([]string{"uploads/note-a.txt"}); len(got) != 0 {
|
||||
t.Fatalf("pièce jointe d'une autre discussion retenue : %+v", got)
|
||||
}
|
||||
if got := attachFiles([]string{"uploads/note-b.txt"}); len(got) != 1 {
|
||||
t.Fatalf("pièce jointe de la discussion courante refusée : %+v", got)
|
||||
}
|
||||
|
||||
// Retour sur a : ses fichiers sont là, ceux de b ont disparu de la vue.
|
||||
if err := convSwitch(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = listedNames(t, "uploads")
|
||||
if !names["note-a.txt"] || names["note-b.txt"] {
|
||||
t.Fatalf("la liste de la discussion a montre %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une discussion emporte TOUS ses fichiers, pas seulement ses
|
||||
// captures — et surtout pas ceux des autres.
|
||||
func TestConvDeleteSupprimeLesFichiers(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
|
||||
a := convEnsureActive()
|
||||
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
|
||||
writeFile(t, filepath.Join(ws, "rapport.md"), "rapport")
|
||||
|
||||
b := convNew()
|
||||
autre := convWorkspace()
|
||||
writeFile(t, filepath.Join(autre, "garde-moi.txt"), "ok")
|
||||
|
||||
if err := convDelete(a); err != nil {
|
||||
t.Fatalf("suppression : %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ws); !os.IsNotExist(err) {
|
||||
t.Fatalf("le dossier de la discussion supprimée est toujours là (%v)", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(autre, "garde-moi.txt")); err != nil {
|
||||
t.Fatalf("les fichiers d'une AUTRE discussion ont été supprimés : %v", err)
|
||||
}
|
||||
if convEnsureActive() != b {
|
||||
t.Fatalf("discussion active = %q, attendu %q", convEnsureActive(), b)
|
||||
}
|
||||
}
|
||||
|
||||
// Vider une discussion (« clear chat ») efface aussi ses fichiers : les messages
|
||||
// qui les mentionnaient partent avec.
|
||||
func TestResetSupprimeLesFichiersDeLaDiscussion(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
|
||||
|
||||
conv.Reset()
|
||||
|
||||
if _, err := os.Stat(filepath.Join(ws, "uploads", "joint.pdf")); !os.IsNotExist(err) {
|
||||
t.Fatalf("le fichier de la discussion vidée a survécu (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant de discussion douteux ne doit JAMAIS devenir un segment de
|
||||
// chemin : dropConvFiles effacerait alors la racine du dossier de travail.
|
||||
func TestDropConvFilesRefuseUnIdDouteux(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "a.txt"), "a")
|
||||
racine := agentWorkspace()
|
||||
|
||||
for _, bad := range []string{"", ".", "..", "../..", "c1/../..", "/etc", `..\..`} {
|
||||
dropConvFiles(bad)
|
||||
if _, err := os.Stat(racine); err != nil {
|
||||
t.Fatalf("id %q a détruit le dossier de travail : %v", bad, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, "a.txt")); err != nil {
|
||||
t.Fatalf("fichier de la discussion détruit : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Les liens des anciens messages doivent continuer d'ouvrir leur fichier : ils
|
||||
// pointent vers uploads/ et captures/<id>/ à la RACINE du dossier de travail.
|
||||
func TestTelechargementDesCheminsDAvant(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
racine := agentWorkspace()
|
||||
id := convEnsureActive()
|
||||
|
||||
writeFile(t, filepath.Join(racine, "uploads", "ancien.txt"), "avant")
|
||||
// Capture d'avant, déplacée par la migration là où elle est désormais rangée.
|
||||
writeFile(t, filepath.Join(convDirFor(id), captureDir, "vue.jpg"), "jpeg")
|
||||
|
||||
for path, want := range map[string]string{
|
||||
"uploads/ancien.txt": "avant",
|
||||
captureDir + "/" + id + "/vue.jpg": "jpeg",
|
||||
captureDir + "/vue.jpg": "jpeg", // le chemin d'aujourd'hui
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(path), nil))
|
||||
if rec.Code != 200 || rec.Body.String() != want {
|
||||
t.Errorf("path=%q : code %d, corps %q", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La migration range les fichiers des versions précédentes : les captures par
|
||||
// leur identifiant, les dépôts par la discussion qui les mentionne. Ce que
|
||||
// personne ne réclame reste à la racine, atteignable par « hors discussion ».
|
||||
func TestMigrationRangeLesFichiersDAvant(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
racine := agentWorkspace()
|
||||
|
||||
a := convEnsureActive()
|
||||
// Un tour avec pièce jointe : c'est cette trace, dans le journal de la
|
||||
// discussion, qui rattache le fichier à elle.
|
||||
state, _ := json.Marshal(map[string]any{
|
||||
"log": []map[string]any{{"seq": 1, "delta": map[string]any{
|
||||
"user": "regarde",
|
||||
"files": []attachInfo{{Name: "joint.pdf", Path: "uploads/joint.pdf", Size: 3}},
|
||||
}}},
|
||||
})
|
||||
if err := putBytes(bkChat, convKey(a), state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(racine, "uploads", "joint.pdf"), "pdf")
|
||||
writeFile(t, filepath.Join(racine, "uploads", "orphelin.bin"), "??")
|
||||
writeFile(t, filepath.Join(racine, captureDir, a, "vue.jpg"), "jpeg")
|
||||
|
||||
// La migration ne joue qu'une fois par process : on rearme le verrou pour
|
||||
// pouvoir l'observer ici.
|
||||
convFilesMigrateOnce = sync.Once{}
|
||||
migrateConvFiles()
|
||||
|
||||
if _, err := os.Stat(filepath.Join(convDirFor(a), uploadsSub, "joint.pdf")); err != nil {
|
||||
t.Errorf("le dépôt n'a pas rejoint sa discussion : %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(convDirFor(a), captureDir, "vue.jpg")); err != nil {
|
||||
t.Errorf("la capture n'a pas rejoint sa discussion : %v", err)
|
||||
}
|
||||
// Le fichier que personne ne réclame reste lisible plutôt que rattaché au hasard.
|
||||
if _, err := os.Stat(filepath.Join(racine, uploadsSub, "orphelin.bin")); err != nil {
|
||||
t.Errorf("le dépôt orphelin a été perdu : %v", err)
|
||||
}
|
||||
if legacyCount() == 0 {
|
||||
t.Error("le reliquat hors discussion n'est pas signalé à l'UI")
|
||||
}
|
||||
}
|
||||
|
||||
// La vue « hors discussion » ne doit pas devenir une porte dérobée vers les
|
||||
// fichiers des autres discussions.
|
||||
func TestScopeLegacyNeDonnePasLesDiscussions(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
racine := agentWorkspace()
|
||||
writeFile(t, filepath.Join(ws, "secret.txt"), "x")
|
||||
writeFile(t, filepath.Join(racine, "ancien.txt"), "y")
|
||||
|
||||
out := listScope(t, "", "legacy")
|
||||
if out["ok"] != true {
|
||||
t.Fatalf("liste refusée : %v", out)
|
||||
}
|
||||
for _, e := range out["entries"].([]any) {
|
||||
if e.(map[string]any)["name"] == convFilesRoot {
|
||||
t.Fatalf("le dossier des discussions est listé : %v", out)
|
||||
}
|
||||
}
|
||||
// Et il ne se supprime pas d'un clic : ce serait toutes les discussions.
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
|
||||
strings.NewReader(`{"path":`+jsonQuote(convFilesRoot)+`,"scope":"legacy"}`)))
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("suppression du dossier des discussions acceptée : %s", rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, "secret.txt")); err != nil {
|
||||
t.Fatalf("fichier d'une discussion supprimé : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// listedNames renvoie les noms listés dans un dossier du panneau.
|
||||
func listedNames(t *testing.T, dir string) map[string]bool {
|
||||
t.Helper()
|
||||
out := listFiles(t, dir)
|
||||
names := map[string]bool{}
|
||||
if out["ok"] != true {
|
||||
return names
|
||||
}
|
||||
for _, e := range out["entries"].([]any) {
|
||||
names[e.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func listScope(t *testing.T, dir, scope string) map[string]any {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFiles(rec, httptest.NewRequest("GET",
|
||||
"/api/chat/files?dir="+url.QueryEscape(dir)+"&scope="+url.QueryEscape(scope), nil))
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
out["_code"] = rec.Code
|
||||
return out
|
||||
}
|
||||
@@ -30,28 +30,24 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// captureDir : sous-dossier du workspace où atterrissent les captures. Elles
|
||||
// sont rangées PAR DISCUSSION (captures/<id>/…) pour que supprimer une
|
||||
// discussion supprime aussi ses images — sinon elles s'accumulaient sur le
|
||||
// disque sans qu'aucun écran ne les mentionne plus.
|
||||
// captureDir : sous-dossier où atterrissent les captures, À L'INTÉRIEUR du
|
||||
// dossier de la discussion (discussions/<id>/captures/…) — comme les dépôts.
|
||||
// Supprimer une discussion emporte donc ses images, qui sinon s'accumulaient
|
||||
// sur le disque sans qu'aucun écran ne les mentionne plus.
|
||||
const captureDir = "captures"
|
||||
|
||||
// captureDirFor renvoie le dossier de captures d'une discussion (chemin absolu)
|
||||
// et son préfixe relatif, celui qui sert dans les URLs d'affichage.
|
||||
// et son préfixe relatif, celui qui sert dans les URLs d'affichage. Le relatif
|
||||
// part du dossier de la DISCUSSION : /api/chat/image résout d'abord là
|
||||
// (workspaceFile), et les liens des anciens messages — en captures/<id>/… —
|
||||
// continuent d'ouvrir leur image par le chemin de repli.
|
||||
func captureDirFor(convID string) (abs, rel string) {
|
||||
rel = captureDir + "/" + convID
|
||||
return filepath.Join(agentWorkspace(), captureDir, convID), rel
|
||||
return filepath.Join(convDirFor(convID), captureDir), captureDir
|
||||
}
|
||||
|
||||
// dropConvCaptures supprime les captures d'une discussion. Appelé quand on la
|
||||
// supprime ou qu'on la vide. Best-effort : un échec ne doit rien interrompre.
|
||||
func dropConvCaptures(convID string) {
|
||||
if convID == "" {
|
||||
return
|
||||
}
|
||||
abs, _ := captureDirFor(convID)
|
||||
_ = os.RemoveAll(abs)
|
||||
}
|
||||
// Supprimer les captures d'une discussion n'a plus de fonction à soi : elles
|
||||
// vivent dans son dossier, et dropConvFiles (chat_convfiles.go) l'emporte en
|
||||
// entier — dépôts et fichiers écrits par l'agent compris.
|
||||
|
||||
// screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a
|
||||
// son propre délai interne, celui-ci est le garde-fou externe.
|
||||
@@ -188,7 +184,10 @@ func screenshotImageMessage(relPath string) (Message, bool) {
|
||||
if !visionEnabled() || !engineSeesImages() {
|
||||
return Message{}, false
|
||||
}
|
||||
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath))
|
||||
abs, ok := workspaceFile(relPath)
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
mime := imageMime(abs)
|
||||
if mime == "" {
|
||||
return Message{}, false
|
||||
|
||||
@@ -11,6 +11,10 @@ package loki
|
||||
// bkChat/active → identifiant de la discussion ouverte
|
||||
// bkChat/conv:<id> → l'état complet d'une discussion (JSON de Conversation)
|
||||
//
|
||||
// Les FICHIERS suivent le même découpage, sur le disque cette fois :
|
||||
// <workspace>/discussions/<id>/ (chat_convfiles.go). Supprimer une discussion
|
||||
// supprime donc aussi ses fichiers.
|
||||
//
|
||||
// Basculer = enregistrer la discussion courante, charger l'autre en mémoire et
|
||||
// incrémenter l'epoch : les abonnés SSE reçoivent alors {reset:true} et
|
||||
// rejouent le nouveau fil depuis zéro. C'est le mécanisme déjà utilisé par
|
||||
@@ -228,9 +232,10 @@ func convDelete(id string) error {
|
||||
}
|
||||
convIndexSave(next)
|
||||
_ = putBytes(bkChat, convKey(id), nil)
|
||||
// Les captures de cette discussion n'ont plus rien qui les référence : les
|
||||
// garder occuperait le disque pour toujours.
|
||||
dropConvCaptures(id)
|
||||
// Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
|
||||
// é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)
|
||||
if id != getStr(bkChat, ckActive) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
cwd := agentWorkspace()
|
||||
cwd := convWorkspace()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
|
||||
@@ -185,16 +185,17 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
cmd := newShellCmd(ctx, command)
|
||||
// Le shell démarre dans le workspace, pas dans le dossier d'où loki a été
|
||||
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
|
||||
// Le shell démarre dans le dossier de la discussion ouverte, pas dans le
|
||||
// dossier d'où loki a été lancé : un `> notes.txt` du modèle ne doit pas
|
||||
// atterrir sur le Bureau, ni dans une AUTRE discussion.
|
||||
//
|
||||
// Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il
|
||||
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même le
|
||||
// La racine est résolue UNE fois par process (agentWorkspace), donc si elle
|
||||
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même la
|
||||
// supprime — toutes les commandes suivantes échouaient sur un « chdir : no
|
||||
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
|
||||
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
|
||||
// tout refuser.
|
||||
if ws := agentWorkspace(); ws != "" {
|
||||
if ws := convWorkspace(); ws != "" {
|
||||
if err := os.MkdirAll(ws, 0o755); err == nil {
|
||||
cmd.Dir = ws
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ import (
|
||||
// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS
|
||||
// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
|
||||
// dossier précis, ça doit marcher.
|
||||
//
|
||||
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
|
||||
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
|
||||
// qu'un fichier appartienne à la discussion où il est né et disparaisse avec
|
||||
// elle. La racine reste la borne de sécurité des téléchargements.
|
||||
|
||||
const workspaceEnv = "LOKI_WORKSPACE"
|
||||
|
||||
@@ -69,7 +74,8 @@ func workspaceCandidates() []string {
|
||||
}
|
||||
|
||||
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
|
||||
// "~/x" → dans le home de l'utilisateur ; relatif → dans le workspace.
|
||||
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
|
||||
// discussion ouverte.
|
||||
func resolveAgentPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
@@ -83,5 +89,5 @@ func resolveAgentPath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(agentWorkspace(), filepath.FromSlash(p))
|
||||
return filepath.Join(convWorkspace(), filepath.FromSlash(p))
|
||||
}
|
||||
@@ -6,14 +6,19 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le
|
||||
// répertoire courant du processus (Bureau, C:\ProgramData\loki\bin…).
|
||||
// Un chemin relatif du modèle doit atterrir dans le dossier de la DISCUSSION
|
||||
// ouverte, jamais dans le répertoire courant du processus (Bureau,
|
||||
// C:\ProgramData\loki\bin…) ni dans un dossier commun à toutes les discussions.
|
||||
func TestResolveAgentPathRelative(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
got := resolveAgentPath("meteo.json")
|
||||
want := filepath.Join(agentWorkspace(), "meteo.json")
|
||||
want := filepath.Join(ws, "meteo.json")
|
||||
if got != want {
|
||||
t.Fatalf("resolveAgentPath = %q, attendu %q", got, want)
|
||||
}
|
||||
if !strings.Contains(got, convFilesRoot) {
|
||||
t.Fatalf("le fichier n'est pas rangé par discussion : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un chemin absolu demandé explicitement reste intouché.
|
||||
@@ -25,9 +30,10 @@ func TestResolveAgentPathAbsolute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveAgentPathSubdir(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
got := resolveAgentPath("notes/2026/a.txt")
|
||||
if !strings.HasPrefix(got, agentWorkspace()) {
|
||||
t.Fatalf("%q hors du workspace %q", got, agentWorkspace())
|
||||
if !strings.HasPrefix(got, ws) {
|
||||
t.Fatalf("%q hors du dossier de la discussion %q", got, ws)
|
||||
}
|
||||
if strings.Contains(got, "/") && filepath.Separator != '/' {
|
||||
t.Fatalf("séparateurs non normalisés : %q", got)
|
||||
|
||||
@@ -574,7 +574,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// reasoning is enabled we split that out ourselves so the UI's reasoning
|
||||
// bubble works regardless of backend. The ik_llama.cpp fork already sends
|
||||
// reasoning_content, in which case we leave content untouched.
|
||||
reasoningOn := reasoningActive(ReadConfig()["REASONING"])
|
||||
chatCfg := ReadConfig()
|
||||
reasoningOn := reasoningActive(chatCfg["REASONING"])
|
||||
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
|
||||
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
|
||||
reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
|
||||
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
|
||||
// retry the same turn once with tools removed so the model answers in plain
|
||||
// text from the tool results already gathered, instead of dying mid-chat.
|
||||
@@ -611,6 +615,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
if reasoningEffort != "" {
|
||||
payload["reasoning_effort"] = reasoningEffort
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
payload["tools"] = tools
|
||||
// The model sometimes emits parallel tool calls, which this llama.cpp
|
||||
|
||||
+67
-14
@@ -40,15 +40,67 @@ import (
|
||||
// oaiSuffix est le domaine sous lequel on autorise l'émission de certificats.
|
||||
const oaiSuffix = ".oai.ajean.link"
|
||||
|
||||
// viaTunnelHeader marque une requête arrivée par le tunnel du relais (posé par
|
||||
// withLocalAuth, qui l'efface d'abord — un client ne peut donc pas le forger).
|
||||
// Il sert à garder la promesse du tunnel : hors du front TLS dédié, la surface
|
||||
// des complétions y reste fermée tant que oaiPublicEnabled() est faux.
|
||||
const viaTunnelHeader = "X-Loki-Via"
|
||||
|
||||
// sendOAIError répond au format d'erreur d'OpenAI. Les SDK officiels lisent
|
||||
// body.error.message ; un {"error":"…"} plat leur fait afficher un message vide,
|
||||
// et certains clients (LiteLLM, LangChain) échouent carrément au décodage.
|
||||
func sendOAIError(w http.ResponseWriter, status int, msg, typ, code string) {
|
||||
sendJSON(w, status, map[string]any{"error": map[string]any{
|
||||
"message": msg, "type": typ, "param": nil, "code": code,
|
||||
}})
|
||||
}
|
||||
|
||||
// mountOAI branche la surface compatible OpenAI sur le mux de Loki, protégée par
|
||||
// la clé des complétions (web_auth.go).
|
||||
//
|
||||
// C'est ce qui rend l'API joignable par le nom de domaine ou l'IP de
|
||||
// L'INTERFACE, sans publier de second port : le moteur peut rester sur la boucle
|
||||
// locale, et un reverse proxy devant Loki suffit pour le TLS. L'adresse annoncée
|
||||
// auparavant (celle du moteur, port 8080) n'était joignable dans aucun
|
||||
// déploiement en conteneur, où ce port n'est pas publié.
|
||||
//
|
||||
// ⚠️ On ne monte QUE /v1/. Surtout pas /health, /props, /metrics ni /slots — que
|
||||
// oaiHandler autorise pour le tunnel : ils divulgueraient le modèle chargé, la
|
||||
// taille de contexte et l'état des slots, sans authentification quand aucune clé
|
||||
// n'est définie. Le filtre interne d'oaiHandler reste en seconde barrière.
|
||||
func mountOAI(mux *http.ServeMux) {
|
||||
mux.Handle("/v1/", requireCompletionKey(oaiHandler()))
|
||||
}
|
||||
|
||||
// oaiHandler construit le reverse-proxy vers llama-server, restreint à la surface
|
||||
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le SaaS envoie
|
||||
// la vraie clé (.api_key), que llama-server valide lui-même (--api-key).
|
||||
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le client
|
||||
// envoie la vraie clé, que llama-server valide lui-même (--api-key). Elle est
|
||||
// donc vérifiée deux fois — par Loki puis par le moteur — et c'est voulu : le
|
||||
// moteur reste protégé même si on l'expose un jour en direct.
|
||||
func oaiHandler() http.Handler {
|
||||
llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
|
||||
lp := httputil.NewSingleHostReverseProxy(llama)
|
||||
lp.FlushInterval = -1 // streaming SSE des complétions
|
||||
lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway)
|
||||
lp := &httputil.ReverseProxy{
|
||||
// FlushInterval négatif = on vide le tampon à chaque écriture : c'est ce
|
||||
// qui fait arriver les tokens un par un chez le client au lieu d'un bloc
|
||||
// en fin de génération.
|
||||
// ⚠️ Ne JAMAIS envelopper le ResponseWriter sur ce chemin (compteur,
|
||||
// journalisation…) : un wrapper qui n'implémente pas http.Flusher
|
||||
// retransforme le flux en réponse bufferisée, sans autre symptôme qu'une
|
||||
// attente inexplicable.
|
||||
FlushInterval: -1,
|
||||
// Rewrite (et non Director) : le port du moteur est relu À CHAQUE
|
||||
// REQUÊTE. Capturé à la construction, il figeait l'ancienne valeur dès
|
||||
// qu'on changeait PORT depuis l'interface, et le proxy visait dans le
|
||||
// vide jusqu'au redémarrage de Loki. Rewrite n'hérite pas non plus des
|
||||
// X-Forwarded-* entrants : un client ne peut pas les forger vers le
|
||||
// moteur.
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
|
||||
pr.Out.Host = pr.In.Host
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
sendOAIError(w, http.StatusBadGateway,
|
||||
"moteur injoignable : "+e.Error(), "api_error", "upstream_unavailable")
|
||||
},
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
@@ -56,7 +108,8 @@ func oaiHandler() http.Handler {
|
||||
lp.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "not found (endpoint OpenAI: /v1/*)", http.StatusNotFound)
|
||||
sendOAIError(w, http.StatusNotFound,
|
||||
"chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,14 +125,14 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
|
||||
return srv.Serve(tls.NewListener(rawLn, tlsCfg))
|
||||
}
|
||||
|
||||
// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette
|
||||
// machine. Piloté par l'UI et lu en direct → activable/coupable sans redémarrer
|
||||
// le service de lien.
|
||||
// oaiPublicEnabled indique si le front TLS du tunnel doit servir la surface
|
||||
// OpenAI. Drapeau désormais en LECTURE SEULE : plus aucune interface ne
|
||||
// l'allume, depuis que l'endpoint est servi par Loki sur son propre port (voir
|
||||
// mountOAI). Il ne subsiste que pour les installations migrées depuis AJEAN dont
|
||||
// le tunnel sert encore ce front — et pour son défaut, qui est le refus :
|
||||
// demuxTunnelStream ferme le flux quand il est absent.
|
||||
func oaiPublicEnabled() bool { return getBool(bkState, "oai_public") }
|
||||
|
||||
// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public.
|
||||
func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) }
|
||||
|
||||
// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via
|
||||
// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et
|
||||
// répond elle-même aux challenges ACME. La clé privée est stockée dans
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package loki
|
||||
|
||||
// Tests de la surface compatible OpenAI servie par Loki (mountOAI).
|
||||
//
|
||||
// Elle est joignable partout où l'interface l'est : c'est tout l'intérêt, et
|
||||
// c'est aussi ce qui rend sa garde d'accès critique. Ces tests verrouillent les
|
||||
// quatre promesses : la clé est exigée quand elle existe, l'amont n'est jamais
|
||||
// appelé quand elle manque, l'en-tête du client arrive INTACT au moteur (qui la
|
||||
// revalide), et rien d'autre que /v1 n'est monté.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fauxMoteur monte un llama-server de comédie et pointe la configuration
|
||||
// dessus : le proxy vise 127.0.0.1:LLMPort(), c'est donc tout ce qu'il faut.
|
||||
// Renvoie le serveur et un pointeur sur les en-têtes de la dernière requête
|
||||
// reçue (nil tant qu'il n'a rien reçu).
|
||||
func fauxMoteur(t *testing.T, h http.HandlerFunc) (*httptest.Server, func() http.Header) {
|
||||
t.Helper()
|
||||
var vues http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
vues = r.Header.Clone()
|
||||
if h != nil {
|
||||
h(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"loki"}]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
port := srv.URL[strings.LastIndexByte(srv.URL, ':')+1:]
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
t.Fatalf("port du faux moteur illisible : %q", srv.URL)
|
||||
}
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return srv, func() http.Header { return vues }
|
||||
}
|
||||
|
||||
// appelOAI joue une requête à travers la garde + le proxy, sans monter tout le
|
||||
// serveur web (newWebMux a des effets de bord : MCP, migrations, conversation).
|
||||
func appelOAI(t *testing.T, méthode, chemin, clé string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(méthode, chemin, nil)
|
||||
if clé != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+clé)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
requireCompletionKey(oaiHandler()).ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// erreurOAI décode le corps au format d'erreur d'OpenAI — celui que les SDK
|
||||
// savent présenter à l'utilisateur.
|
||||
func erreurOAI(t *testing.T, rec *httptest.ResponseRecorder) (message, code string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("corps illisible (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return body.Error.Message, body.Error.Code
|
||||
}
|
||||
|
||||
// Sans clé configurée, l'endpoint est ouvert — comme llama-server sans
|
||||
// --api-key. Le panneau et le démarrage le disent en rouge ; ici on vérifie
|
||||
// seulement que le comportement est bien celui-là, et pas un refus silencieux.
|
||||
func TestOAISansCleLaissePasser(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
rec := appelOAI(t, "GET", "/v1/models", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"loki"`) {
|
||||
t.Errorf("réponse du moteur non relayée : %s", rec.Body.String())
|
||||
}
|
||||
if vues() == nil {
|
||||
t.Error("le moteur n'a pas été appelé")
|
||||
}
|
||||
}
|
||||
|
||||
// La bonne clé passe, ET l'en-tête arrive INTACT au moteur : c'est le contrat.
|
||||
// llama-server la valide lui aussi (--api-key), il reste donc protégé même si
|
||||
// on l'expose un jour en direct.
|
||||
func TestOAIBonneCleEtEnTeteRelayee(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
if err := writeAPIKey("sk-loki-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := appelOAI(t, "GET", "/v1/models", "sk-loki-test")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := vues().Get("Authorization"); got != "Bearer sk-loki-test" {
|
||||
t.Errorf("en-tête transmis au moteur = %q, attendu la clé du client", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Mauvaise clé, et clé absente : 401 au format OpenAI, et le moteur n'est JAMAIS
|
||||
// appelé — une garde qui laisse passer la requête avant de refuser ne protège
|
||||
// rien.
|
||||
func TestOAICleRefusee(t *testing.T) {
|
||||
for _, cas := range []struct{ nom, envoyée string }{
|
||||
{"mauvaise clé", "sk-loki-pasbonne"},
|
||||
{"aucune clé", ""},
|
||||
} {
|
||||
t.Run(cas.nom, func(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
if err := writeAPIKey("sk-loki-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := appelOAI(t, "POST", "/v1/chat/completions", cas.envoyée)
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("code %d, attendu 401 — corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Error("en-tête WWW-Authenticate absent")
|
||||
}
|
||||
msg, code := erreurOAI(t, rec)
|
||||
if code != "invalid_api_key" || msg == "" {
|
||||
t.Errorf("erreur = {code:%q, message:%q}", code, msg)
|
||||
}
|
||||
if vues() != nil {
|
||||
t.Error("le moteur a été appelé malgré le refus")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La clé résiduelle de config.env fait foi quand la base n'en a pas : c'est
|
||||
// celle que le moteur exige (backend_serve.go). Sans ce miroir, Loki laissait
|
||||
// passer et llama refusait — un 401 que rien n'expliquait.
|
||||
func TestOAICleResiduelleDeConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
if err := SetConfigKey("API_KEY", "sk-ancienne"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := appelOAI(t, "GET", "/v1/models", ""); rec.Code != 401 {
|
||||
t.Fatalf("sans clé : code %d, attendu 401", rec.Code)
|
||||
}
|
||||
if vues() != nil {
|
||||
t.Fatal("le moteur a été appelé malgré le refus")
|
||||
}
|
||||
if rec := appelOAI(t, "GET", "/v1/models", "sk-ancienne"); rec.Code != 200 {
|
||||
t.Fatalf("avec la clé de config.env : code %d, attendu 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Un préflight CORS ne porte jamais d'Authorization. Le refuser casserait tout
|
||||
// client tiers qui tourne dans un navigateur.
|
||||
func TestOAIPreflightPasseSansCle(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
if err := writeAPIKey("sk-loki-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := appelOAI(t, "OPTIONS", "/v1/chat/completions", ""); rec.Code == 401 {
|
||||
t.Fatalf("préflight refusé : %s", rec.Body.String())
|
||||
}
|
||||
if vues() == nil {
|
||||
t.Error("le préflight n'a pas atteint le moteur, qui répond ses en-têtes CORS")
|
||||
}
|
||||
}
|
||||
|
||||
// Seul /v1/ est monté. /metrics, /props et /slots divulgueraient le modèle
|
||||
// chargé et l'état du moteur — sans clé, à qui joint l'adresse.
|
||||
func TestOAIMonteSeulementV1(t *testing.T) {
|
||||
testHome(t)
|
||||
mux := http.NewServeMux()
|
||||
mountOAI(mux)
|
||||
témoin := false
|
||||
mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { témoin = true })
|
||||
|
||||
for _, chemin := range []string{"/metrics", "/props", "/slots", "/api/ping", "/"} {
|
||||
témoin = false
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", chemin, nil))
|
||||
if !témoin {
|
||||
t.Errorf("%s n'a pas été servi par le handler racine — il est passé à l'endpoint OpenAI", chemin)
|
||||
}
|
||||
}
|
||||
// …et /v1/, lui, ne va PAS au handler racine.
|
||||
témoin = false
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil))
|
||||
if témoin {
|
||||
t.Error("/v1/models a été servi par le handler racine")
|
||||
}
|
||||
}
|
||||
|
||||
// Le streaming doit arriver en morceaux. Si FlushInterval régresse, le premier
|
||||
// événement n'arrive qu'à la fin de la génération — invisible en test unitaire
|
||||
// classique, d'où ce test qui bloque l'amont entre deux écritures.
|
||||
func TestOAIStreamingArriveEnMorceaux(t *testing.T) {
|
||||
testHome(t)
|
||||
débloque := make(chan struct{})
|
||||
fauxMoteur(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Error("le faux moteur ne sait pas vider son tampon")
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("data: un\n\n"))
|
||||
fl.Flush()
|
||||
<-débloque
|
||||
_, _ = w.Write([]byte("data: deux\n\n"))
|
||||
fl.Flush()
|
||||
})
|
||||
|
||||
front := httptest.NewServer(requireCompletionKey(oaiHandler()))
|
||||
defer front.Close()
|
||||
defer close(débloque)
|
||||
|
||||
resp, err := http.Get(front.URL + "/v1/chat/completions")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
lu := make(chan string, 1)
|
||||
go func() {
|
||||
ligne, _ := bufio.NewReader(resp.Body).ReadString('\n')
|
||||
lu <- ligne
|
||||
}()
|
||||
select {
|
||||
case ligne := <-lu:
|
||||
if !strings.Contains(ligne, "un") {
|
||||
t.Fatalf("première ligne = %q", ligne)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("rien reçu avant la fin de la génération : le flux est bufferisé")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package loki
|
||||
|
||||
// Catalogue MCP embarqué.
|
||||
//
|
||||
// Ajouter un serveur MCP demandait jusqu'ici d'écrire soi-même la commande et
|
||||
// ses arguments (`npx -y @scope/paquet …`) dans la modale. Le catalogue fournit
|
||||
// une liste de serveurs connus, déjà renseignés : un clic préremplit la modale,
|
||||
// l'utilisateur relit la commande et enregistre.
|
||||
//
|
||||
// Le catalogue est un JSON EMBARQUÉ dans le binaire (go:embed), pas un appel à
|
||||
// un annuaire distant : cohérent avec un Loki qui tourne hors ligne, et rien de
|
||||
// ce qui s'exécute sur la machine ne vient du réseau. Pour proposer un serveur
|
||||
// de plus : éditer mcp_catalog.json et recompiler.
|
||||
//
|
||||
// SÉCURITÉ : le catalogue ne fait qu'AFFICHER une commande. Il n'installe ni
|
||||
// n'exécute rien tout seul — c'est l'enregistrement du serveur par
|
||||
// l'utilisateur, via la modale existante, qui décide. Même niveau de confiance
|
||||
// que l'outil `bash` du mode agent (voir l'en-tête de mcp_config.go).
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed mcp_catalog.json
|
||||
var mcpCatalogRaw []byte
|
||||
|
||||
// MCPCatalogEntry décrit un serveur proposé par le catalogue. Les champs de
|
||||
// transport reprennent ceux de MCPServerConfig : l'UI peut les recopier tels
|
||||
// quels dans la modale d'ajout.
|
||||
type MCPCatalogEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
|
||||
// Runtime : binaire nécessaire pour lancer le serveur ("node" → npx,
|
||||
// "python" → uvx). Sert à signaler une entrée non installable ici.
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
|
||||
// Secrets : variables d'environnement que l'utilisateur DOIT renseigner
|
||||
// (jetons, clés d'API). L'UI les met en évidence ; elles sont volontairement
|
||||
// vides dans le catalogue.
|
||||
Secrets []string `json:"secrets,omitempty"`
|
||||
|
||||
// Renseignés à la lecture, pas dans le fichier.
|
||||
Installed bool `json:"installed"`
|
||||
RuntimeOK bool `json:"runtimeOk"`
|
||||
}
|
||||
|
||||
type mcpCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Servers []MCPCatalogEntry `json:"servers"`
|
||||
}
|
||||
|
||||
var (
|
||||
mcpCatalogOnce sync.Once
|
||||
mcpCatalogParsed []MCPCatalogEntry
|
||||
mcpCatalogParseEr error
|
||||
)
|
||||
|
||||
// mcpCatalogEntries décode le JSON embarqué une seule fois. Le contenu est figé
|
||||
// dans le binaire : inutile de le relire à chaque appel.
|
||||
func mcpCatalogEntries() ([]MCPCatalogEntry, error) {
|
||||
mcpCatalogOnce.Do(func() {
|
||||
var f mcpCatalogFile
|
||||
if err := json.Unmarshal(mcpCatalogRaw, &f); err != nil {
|
||||
mcpCatalogParseEr = fmt.Errorf("catalogue MCP illisible : %w", err)
|
||||
return
|
||||
}
|
||||
mcpCatalogParsed = f.Servers
|
||||
})
|
||||
return mcpCatalogParsed, mcpCatalogParseEr
|
||||
}
|
||||
|
||||
// mcpRuntimeAvailable indique si le binaire qui lance ce type de serveur est
|
||||
// présent. L'image conteneur embarque Node ; uv/uvx peut manquer.
|
||||
func mcpRuntimeAvailable(runtime string) bool {
|
||||
switch runtime {
|
||||
case "node":
|
||||
return lookPathOK("npx")
|
||||
case "python":
|
||||
return lookPathOK("uvx")
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func lookPathOK(bin string) bool {
|
||||
_, err := exec.LookPath(bin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// MCPCatalog renvoie les entrées du catalogue, chemins résolus et marquées
|
||||
// « déjà installée » (un serveur configuré porte le même nom que l'entrée).
|
||||
func MCPCatalog() ([]MCPCatalogEntry, error) {
|
||||
entries, err := mcpCatalogEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configured, err := LoadMCPConfig()
|
||||
if err != nil {
|
||||
// Une config MCP illisible ne doit pas vider le catalogue : on affiche
|
||||
// les entrées sans savoir lesquelles sont déjà là.
|
||||
configured = map[string]MCPServerConfig{}
|
||||
}
|
||||
|
||||
out := make([]MCPCatalogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
e.Args = expandCatalogSlice(e.Args)
|
||||
e.Env = expandCatalogMap(e.Env)
|
||||
_, e.Installed = configured[e.ID]
|
||||
e.RuntimeOK = mcpRuntimeAvailable(e.Runtime)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// expandCatalogToken remplace les jetons de chemin du catalogue. Seul
|
||||
// {workspace} existe : c'est le dossier de travail de l'agent, le seul chemin
|
||||
// qu'un serveur du catalogue a une raison légitime de recevoir par défaut.
|
||||
func expandCatalogToken(s string) string {
|
||||
if !strings.Contains(s, "{workspace}") {
|
||||
return s
|
||||
}
|
||||
return strings.ReplaceAll(s, "{workspace}", workspaceDir())
|
||||
}
|
||||
|
||||
func expandCatalogSlice(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = expandCatalogToken(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expandCatalogMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = expandCatalogToken(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "Catalogue MCP embarqué (hors ligne, aucun annuaire distant). Éditable à la main : ajoute une entrée pour la proposer dans l'interface, puis relance le build. Le jeton {workspace} est remplacé par le dossier de travail de l'agent. Les entrées « uvx --with mcp<2 … » visent des serveurs publiés avant le SDK MCP 2.0 : sans cet épinglage, ils plantent au démarrage (ImportError McpError).",
|
||||
"servers": [
|
||||
{
|
||||
"id": "filesystem",
|
||||
"name": "Filesystem",
|
||||
"category": "Fichiers",
|
||||
"description": "Lecture, écriture et recherche de fichiers, limitées aux dossiers passés en argument.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "{workspace}"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Git",
|
||||
"category": "Code",
|
||||
"description": "Historique, diff et statut d'un dépôt Git ; commits et branches.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-git", "--repository", "{workspace}"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"name": "Fetch",
|
||||
"category": "Web",
|
||||
"description": "Télécharge une URL et la convertit en Markdown pour le modèle.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-fetch"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"name": "Memory",
|
||||
"category": "Agent",
|
||||
"description": "Mémoire sous forme de graphe de connaissances (entités, relations, observations).",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-memory"],
|
||||
"env": { "MEMORY_FILE_PATH": "{workspace}/mcp-memory.json" },
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "sequential-thinking",
|
||||
"name": "Sequential Thinking",
|
||||
"category": "Agent",
|
||||
"description": "Structure le raisonnement en étapes révisables. Utile aux petits modèles locaux.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "time",
|
||||
"name": "Time",
|
||||
"category": "Utilitaires",
|
||||
"description": "Heure courante et conversions de fuseaux horaires.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-time"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"name": "SQLite",
|
||||
"category": "Données",
|
||||
"description": "Interroge et modifie une base SQLite locale.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/sqlite",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-sqlite", "--db-path", "{workspace}/agent.db"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "everything",
|
||||
"name": "Everything (test)",
|
||||
"category": "Utilitaires",
|
||||
"description": "Serveur de référence exposant toutes les primitives MCP. Sert à vérifier l'intégration.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "duckduckgo",
|
||||
"name": "DuckDuckGo",
|
||||
"category": "Web",
|
||||
"description": "Recherche web via DuckDuckGo, sans clé d'API.",
|
||||
"homepage": "https://pypi.org/project/duckduckgo-mcp-server/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["duckduckgo-mcp-server"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "playwright",
|
||||
"name": "Playwright",
|
||||
"category": "Web",
|
||||
"description": "Pilote un navigateur (navigation, clics, formulaires) via l'arbre d'accessibilité.",
|
||||
"homepage": "https://github.com/microsoft/playwright-mcp",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "puppeteer",
|
||||
"name": "Puppeteer",
|
||||
"category": "Web",
|
||||
"description": "Automatisation d'un Chrome sans interface (alternative à Playwright).",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/puppeteer",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "context7",
|
||||
"name": "Context7",
|
||||
"category": "Code",
|
||||
"description": "Documentation à jour des bibliothèques, injectée directement dans le contexte.",
|
||||
"homepage": "https://github.com/upstash/context7",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"name": "GitHub",
|
||||
"category": "Code",
|
||||
"description": "Dépôts, issues et pull requests via l'API GitHub. Nécessite un jeton personnel.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" },
|
||||
"secrets": ["GITHUB_PERSONAL_ACCESS_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "postgres",
|
||||
"name": "PostgreSQL",
|
||||
"category": "Données",
|
||||
"description": "Requêtes SQL en lecture seule et inspection du schéma d'une base PostgreSQL.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-postgres",
|
||||
"postgresql://utilisateur:motdepasse@hote:5432/base"
|
||||
],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"name": "Docker",
|
||||
"category": "Infra",
|
||||
"description": "Gère conteneurs et stacks compose. Nécessite l'accès au socket Docker.",
|
||||
"homepage": "https://pypi.org/project/docker-mcp/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "docker-mcp"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "brave-search",
|
||||
"name": "Brave Search",
|
||||
"category": "Web",
|
||||
"description": "Recherche web et locale via l'API Brave. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": { "BRAVE_API_KEY": "" },
|
||||
"secrets": ["BRAVE_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "firecrawl",
|
||||
"name": "Firecrawl",
|
||||
"category": "Web",
|
||||
"description": "Scraping et crawl de sites avec extraction structurée. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/firecrawl/firecrawl-mcp-server",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"env": { "FIRECRAWL_API_KEY": "" },
|
||||
"secrets": ["FIRECRAWL_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "notion",
|
||||
"name": "Notion",
|
||||
"category": "Productivité",
|
||||
"description": "Lecture et écriture de pages et bases Notion. Nécessite un jeton d'intégration.",
|
||||
"homepage": "https://github.com/makenotion/notion-mcp-server",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@notionhq/notion-mcp-server"],
|
||||
"env": { "NOTION_TOKEN": "" },
|
||||
"secrets": ["NOTION_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "slack",
|
||||
"name": "Slack",
|
||||
"category": "Productivité",
|
||||
"description": "Lecture des canaux et envoi de messages Slack. Nécessite un jeton de bot.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": { "SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": "" },
|
||||
"secrets": ["SLACK_BOT_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "google-maps",
|
||||
"name": "Google Maps",
|
||||
"category": "Utilitaires",
|
||||
"description": "Géocodage, itinéraires et recherche de lieux. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/google-maps",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-google-maps"],
|
||||
"env": { "GOOGLE_MAPS_API_KEY": "" },
|
||||
"secrets": ["GOOGLE_MAPS_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "sentry",
|
||||
"name": "Sentry",
|
||||
"category": "Infra",
|
||||
"description": "Récupère les issues et traces d'erreur d'un projet Sentry.",
|
||||
"homepage": "https://pypi.org/project/mcp-server-sentry/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-sentry", "--auth-token", "REMPLACE_MOI"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le catalogue est embarqué : s'il ne décode pas, le binaire livré est cassé et
|
||||
// le panneau MCP part vide. Ce test attrape une virgule en trop au build.
|
||||
func TestMCPCatalogDecodes(t *testing.T) {
|
||||
entries, err := mcpCatalogEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("catalogue illisible : %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("catalogue vide")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.ID == "" || e.Name == "" || e.Command == "" {
|
||||
t.Errorf("entrée incomplète : %+v", e)
|
||||
}
|
||||
if seen[e.ID] {
|
||||
t.Errorf("id dupliqué : %s", e.ID)
|
||||
}
|
||||
seen[e.ID] = true
|
||||
if e.Description == "" {
|
||||
t.Errorf("%s : description manquante", e.ID)
|
||||
}
|
||||
// Un secret annoncé doit exister comme variable d'environnement, sinon
|
||||
// l'UI met en avant un champ que l'utilisateur ne peut pas remplir.
|
||||
for _, s := range e.Secrets {
|
||||
if _, ok := e.Env[s]; !ok {
|
||||
t.Errorf("%s : secret %q absent de env", e.ID, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les jetons {workspace} doivent disparaître : un serveur lancé avec un chemin
|
||||
// littéral « {workspace} » démarre puis échoue à la première lecture.
|
||||
func TestMCPCatalogExpandsWorkspaceToken(t *testing.T) {
|
||||
entries, err := MCPCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("MCPCatalog : %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
for _, a := range e.Args {
|
||||
if strings.Contains(a, "{workspace}") {
|
||||
t.Errorf("%s : jeton non résolu dans args : %s", e.ID, a)
|
||||
}
|
||||
}
|
||||
for k, v := range e.Env {
|
||||
if strings.Contains(v, "{workspace}") {
|
||||
t.Errorf("%s : jeton non résolu dans env[%s] : %s", e.ID, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningEffortValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
" ": "",
|
||||
"auto": "",
|
||||
"default": "",
|
||||
"off": "none",
|
||||
"none": "none",
|
||||
"false": "none",
|
||||
"0": "none",
|
||||
"low": "low",
|
||||
"MEDIUM": "medium",
|
||||
" High ": "high",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := reasoningEffortValue(in); got != want {
|
||||
t.Errorf("reasoningEffortValue(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,6 +541,18 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
|
||||
// à chaque requête de l'autre côté.
|
||||
func withLocalAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// ⚠️ Marquage AVANT tout : on efface d'abord, sinon un client forge
|
||||
// l'en-tête et se fait passer pour du trafic de tunnel (ou l'inverse).
|
||||
r.Header.Del(viaTunnelHeader)
|
||||
r.Header.Set(viaTunnelHeader, "tunnel")
|
||||
// La surface des complétions a SA clé (celle du moteur, fournie par le
|
||||
// client). Y injecter la clé de PILOTAGE la ferait refuser par la garde,
|
||||
// et surtout la relaierait à llama-server — un secret d'administration
|
||||
// envoyé à un process qui n'en a aucun usage.
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if webKey := readWebKey(); webKey != "" && r.Header.Get("Authorization") == "" {
|
||||
r.Header.Set("Authorization", "Bearer "+webKey)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package loki
|
||||
|
||||
// sys_brand_icon.go — l'icône de la marque Loki, rendue à la volée.
|
||||
//
|
||||
// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis NOIR + « j »
|
||||
// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis SAUGE + « j »
|
||||
// blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). Une seule source pour
|
||||
// tous les usages — zone de notification Windows, barre de menus macOS, icône du
|
||||
// .exe — pour qu'ils ne puissent plus diverger comme quand le favicon est passé
|
||||
// au noir en laissant les icônes système en bleu.
|
||||
//
|
||||
// La sauge est --accent de l'interface (#5E7F5A) : l'icône de l'onglet et celle
|
||||
// du bureau portent la couleur de l'app, pas un carré noir anonyme.
|
||||
//
|
||||
// Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est
|
||||
// produite par `go generate ./cmd/loki` (voir tools/gen-icon).
|
||||
|
||||
@@ -22,6 +25,8 @@ import (
|
||||
const trayIconSize = 32
|
||||
|
||||
var (
|
||||
// brandSage = --accent du thème clair. Le blanc du « j » s'y lit à 4,9:1.
|
||||
brandSage = color.RGBA{0x5e, 0x7f, 0x5a, 0xff}
|
||||
brandBlack = color.RGBA{0x00, 0x00, 0x00, 0xff}
|
||||
brandWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
|
||||
brandClear = color.RGBA{0, 0, 0, 0}
|
||||
@@ -80,9 +85,9 @@ func encodePNG(img *image.RGBA) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// BrandIconPNG rend l'icône de marque (noir + « j » blanc) en PNG de n pixels.
|
||||
// BrandIconPNG rend l'icône de marque (sauge + « j » blanc) en PNG de n pixels.
|
||||
// Exporté pour le générateur d'icône du .exe (tools/gen-icon).
|
||||
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) }
|
||||
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandSage, brandWhite)) }
|
||||
|
||||
// brandTemplatePNG rend la variante « template » attendue par macOS : seule la
|
||||
// couche alpha compte, le système colore la forme selon le thème de la barre de
|
||||
|
||||
@@ -75,6 +75,7 @@ var configTemplate = []struct{ key, help string }{
|
||||
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
|
||||
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
|
||||
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
|
||||
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high"},
|
||||
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
|
||||
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
|
||||
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1311
-289
File diff suppressed because it is too large.
Load diff
@@ -12,8 +12,8 @@
|
||||
<!-- Favicon (même marque « loki » que la landing/portail) + icône PWA (écran d'accueil
|
||||
iOS). SVG inline pour le favicon, PNG 180×180 en data-URI pour apple-touch-icon
|
||||
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%23000'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
|
||||
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABBUlEQVR42u3SAQkAAAwCQfuX3lI4GNxF0E8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIArU2NbcYhDHOIQhzgQB+JAHIgDcSAOxIE4xCEOcYhDHIgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQByIQxziEIc4xIE4EAfiQByIA3EgDsQhDnGIQxziEIc4EAfiQByIAxciDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAciEMc4hCHOMQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADfLBHMhi4TnG6EAAAAAElFTkSuQmCC">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
|
||||
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAYAAAA9zQYyAAADD0lEQVR4nOzdW27aWgBGYag6ylZqh1I6lFZKppmjEylRLlyMsbG9+n3vsP2w8mfDC193d/Dj17ene5zDuv39/bif+4xZDhAwQ8wR+GRvKGJuMVXcN7+JkJnSrWGPfrGQmdPYsL+MeZGYmdvYxq76KxAyS7hmrQcvtJhZyjXtDQpazCxtaIMXgxYzazGkxbNBi5m1udTkyaDFzFqda3PU13awVkeDts6s3alGPwUtZrbiWKuuHKS8C9o6szUfm7XQpLwGbZ3ZqrftWmhSBE3Kc9CuG2zdS8MWmhRBkyJoUvbuz5RYaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSbnLj9ez2/05PCxy7s/D90XOXYqFJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAialK9LP8C9/Tk8LHLuz8P3Rc7911hoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZl/+PXt6elHwKmYqFJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgibly9/fj/ulHwKm8H/LFpoUQZMiaFKeg3aPZuteGrbQpAialNegXTvYqrftWmhS3gVtpdmaj81aaFI+BW2l2YpjrR5daFGzdqcadeUg5WTQVpq1Otfm2YUWNWtzqcmLVw5RsxZDWhx0hxY1Sxva4OAPhaJmKde0NypSPwXHPYwZ0VFf21lr5ja2sZvDtNZM6daxnGxphc0tpvqvP8vVQdwMMcfV9S53YYGzu9Nnr/8CAAD//xZRk2OJYiOqAAAAAElFTkSuQmCC">
|
||||
<!-- Marque le mode PWA AVANT le rendu : la hauteur de coque et le défilement du
|
||||
document en dépendent (voir styles.css). ⚠️ On NE PEUT PAS se fier au média
|
||||
`display-mode:standalone` : WebKit l'adosse au `display` du MANIFESTE, or cette
|
||||
@@ -22,30 +22,43 @@
|
||||
garde `display-mode` en second pour les PWA Android/desktop à manifeste. -->
|
||||
<script>try{var pwa=(window.navigator.standalone===true)||(window.matchMedia&&window.matchMedia('(display-mode:standalone)').matches)||(window.matchMedia&&window.matchMedia('(display-mode:fullscreen)').matches);document.documentElement.setAttribute('data-pwa',pwa?'1':'0');}catch(e){document.documentElement.setAttribute('data-pwa','0');}</script>
|
||||
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
|
||||
<script>try{var t=localStorage.getItem('loki-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('loki-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
|
||||
<script>try{var t=localStorage.getItem('loki-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('loki-'+k)==='1'?'1':'0')});
|
||||
/* Barre latérale escamotée et moniteur replié : posés AVANT le rendu, sinon la
|
||||
barre s'affiche puis se referme sous les yeux à chaque chargement. Ouverts par
|
||||
défaut — une valeur absente vaut « visible ». */
|
||||
document.documentElement.setAttribute('data-side',localStorage.getItem('loki-side')==='0'?'0':'1');
|
||||
}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
|
||||
<style>
|
||||
@@CSS@@
|
||||
</style></head><body>
|
||||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||||
<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
|
||||
fermé sur téléphone. Sans ce rappel, un rechargement de page donnait
|
||||
l'impression que l'installation avait disparu. -->
|
||||
<button id="lc-chip" onclick="lcChipOpen()" hidden></button>
|
||||
<div id="backdrop" onclick="toggleSide()"></div>
|
||||
<div class="side" id="side">
|
||||
<!-- sidecard : regroupe la marque et la mention du fork en UNE carte (direction
|
||||
Terre). Sans ce conteneur, les deux blocs flottaient séparément. -->
|
||||
<div class="sidecard">
|
||||
<!-- Trois zones : en-tête figé, contenu défilant, moniteur machine en pied.
|
||||
C'est ce découpage qui garde les jauges visibles quel que soit le nombre
|
||||
de discussions — elles les poussaient auparavant hors de l'écran. -->
|
||||
<div class="sidehead">
|
||||
<div class="sidebrand">
|
||||
<h1 id="brand" aria-label="loki" title="Loki — fork d'AJEAN (github.com/nathaninline/ajean, MIT)"><span id="brand-a"><svg viewBox="0 0 4 5" shape-rendering="crispEdges" fill="currentColor"><rect x="0" y="0" width="1" height="5"/><rect x="1" y="4" width="3" height="1"/></svg></span><span id="brand-word"><svg viewBox="0 0 11 5" shape-rendering="crispEdges" fill="currentColor"><rect x="0" y="0" width="3" height="1"/><rect x="0" y="1" width="1" height="3"/><rect x="2" y="1" width="1" height="3"/><rect x="0" y="4" width="3" height="1"/><rect x="4" y="0" width="1" height="5"/><rect x="6" y="0" width="1" height="2"/><rect x="5" y="2" width="1" height="1"/><rect x="6" y="3" width="1" height="2"/><rect x="8" y="0" width="3" height="1"/><rect x="9" y="1" width="1" height="3"/><rect x="8" y="4" width="3" height="1"/></svg></span><span id="ver"></span></h1>
|
||||
<!-- Attribution du fork (licence MIT de l'amont) : sous le nom de l'app,
|
||||
là où elle a toujours vécu. -->
|
||||
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
|
||||
</div><!-- /.sidebrand -->
|
||||
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
||||
</div>
|
||||
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
|
||||
</div><!-- /sidecard -->
|
||||
<div id="side-scroll">
|
||||
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
|
||||
actif est partagé par tous les appareils (comme la conversation unique
|
||||
d'origine) : basculer ici bascule partout. -->
|
||||
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion">+</button></summary>
|
||||
d'origine) : basculer ici bascule partout.
|
||||
La recherche filtre CÔTÉ CLIENT : la liste est déjà entièrement chargée
|
||||
(métadonnées seules, sans les messages), un aller-retour serveur par
|
||||
frappe n'apporterait rien. -->
|
||||
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion" aria-label="nouvelle discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
|
||||
<input id="conv-search" type="search" placeholder="rechercher une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
|
||||
<div id="conv-list" class="slist"></div>
|
||||
</details>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
@@ -58,10 +71,15 @@
|
||||
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
||||
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
||||
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
||||
<!-- Tout ce qui n'est pas la conversation tient sous CE repli : la barre
|
||||
latérale par défaut, c'est les discussions et la machine. Les sections
|
||||
ouvertes par programme (journal du moteur, installation llama.cpp)
|
||||
passent par openDetails(), qui déplie aussi les parents. -->
|
||||
<details id="side-settings"><summary><span class="stitle">Réglages</span></summary>
|
||||
<div class="sidegroup">
|
||||
<details id="svc-log-box" ontoggle="if(this.open){loadSvcLog();showPaths()}"><summary><span class="stitle">Journal du moteur</span></summary>
|
||||
<div class="row" style="justify-content:flex-end;align-items:center">
|
||||
<button onclick="loadSvcLog()" title="rafraîchir">↻</button>
|
||||
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||
</div>
|
||||
<pre id="svc-log" class="muted" style="font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:6px 0 0"></pre>
|
||||
<!-- Emplacements : au même endroit que le journal, c'est le panneau où l'on
|
||||
@@ -71,18 +89,10 @@
|
||||
</div>
|
||||
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
||||
</details>
|
||||
<!-- GPU et RAM décrivent la même chose (les ressources de la machine) :
|
||||
une seule section, deux blocs de statistique dans la même carte. -->
|
||||
<details open><summary><span class="stitle">Machine</span></summary>
|
||||
<div class="slist">
|
||||
<div id="vram">…</div>
|
||||
<div id="ram-details"><div id="ram"></div></div>
|
||||
</div>
|
||||
</details>
|
||||
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
|
||||
<div id="cfg" class="slist">…</div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter">+</button></summary>
|
||||
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
|
||||
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
||||
@@ -158,7 +168,10 @@
|
||||
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
|
||||
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
|
||||
<div id="mcp-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div></div>
|
||||
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
|
||||
<div class="mcp-actions">
|
||||
<button class="addbtn" onclick="openMcpCatalog()">catalogue</button>
|
||||
<button class="addbtn" onclick="openMcp('')">+ ajouter un serveur</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,13 +192,24 @@
|
||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||
étaient des cartes dans une carte). -->
|
||||
<div class="slist">
|
||||
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
|
||||
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
|
||||
<!-- Moteur fourni par l'image Docker : aucun des trois modes d'installation
|
||||
ne s'applique (rien à compiler, et l'image n'embarque ni cmake ni
|
||||
compilateur). En revanche llama.cpp publie plusieurs versions par jour,
|
||||
et son moteur déjà compilé est téléchargeable seul (~170 Mo) : cette
|
||||
carte le met à jour sans reconstruire l'image. -->
|
||||
<div id="lc-provided" class="rowbody" style="display:none">
|
||||
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
|
||||
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
|
||||
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
|
||||
<div style="font-size:12px;line-height:1.5">
|
||||
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
|
||||
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
||||
<div id="eng-latest" style="margin-top:6px"></div>
|
||||
<div class="row" style="margin:8px 0 0">
|
||||
<button onclick="engCheck()">vérifier la version</button>
|
||||
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
||||
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px">Télécharge llama-server et ses bibliothèques depuis l'image officielle
|
||||
<code id="eng-repo"></code> (~170 Mo), sans reconstruire l'image de Loki. Le nouveau moteur est essayé
|
||||
avant d'être adopté ; celui de l'image reste intact et récupérable en un clic.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
|
||||
@@ -269,40 +293,34 @@
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-reasoning" onchange="toggleView('hide-reasoning')"><span class="slider"></span></span> masquer le raisonnement</label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-tools" onchange="toggleView('hide-tools')"><span class="slider"></span></span> masquer les appels d'outils</label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-side" onchange="toggleView('hide-side')"><span class="slider"></span></span> barre latérale escamotable<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">La barre latérale devient un tiroir <b>même sur ordinateur</b> : elle reste fermée et s'ouvre avec le bouton ☰. Toute la largeur pour la conversation.</span></span></label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
||||
</div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Accès OpenAI</span></summary>
|
||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||
<!-- UNE carte pour tout l'endpoint : adresse locale, bascule « public » et
|
||||
adresse publique sont un seul sujet — trois cartes accolées donnaient
|
||||
trois îlots pour un même réglage. -->
|
||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||
<div class="slist">
|
||||
<div class="rowbody">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>loki</code></div>
|
||||
<div class="muted" style="margin:0 0 6px">Adresse de l'endpoint — modèle <code>loki</code></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-url" readonly class="io-input">
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- L'adresse ci-dessus ne sert à RIEN si le moteur n'écoute que sur la
|
||||
boucle locale (le défaut sous Windows) : le chat du navigateur marche,
|
||||
mais aucun logiciel du réseau ne peut s'y brancher. Cet interrupteur
|
||||
règle HOST et pose la règle de pare-feu, voir sys_network.go. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="lan-toggle" onchange="toggleLAN()"><span class="slider"></span></span> joignable depuis le réseau local<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Éteint, le moteur n'écoute que sur <b>cette machine</b> : l'adresse ci-dessus ne répond qu'ici. Allumez-le pour brancher un logiciel depuis un autre ordinateur du réseau (sous Windows, la règle de pare-feu est posée en même temps). <b>Redémarre le moteur.</b></span></span></label>
|
||||
<div id="lan-warn" class="rowbody" style="display:none"></div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend votre IA joignable depuis <b>n'importe où</b> via <code>https://<machine>.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite l'accès distant actif.</span></span></label>
|
||||
<div id="oai-public-wrap" class="rowbody" style="display:none">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse publique — chiffrée de bout en bout, accessible partout</div>
|
||||
<!-- Adresse publique : ce que Loki ne peut pas deviner. Vide, l'adresse
|
||||
ci-dessus est celle par laquelle CE navigateur a ouvert l'interface —
|
||||
déjà juste dans la plupart des cas. Renseignée, elle prend le pas :
|
||||
c'est le cas du reverse proxy, où Loki ne voit qu'un appel interne. -->
|
||||
<div class="rowbody">
|
||||
<div class="muted" style="margin:0 0 6px">Adresse publique <span style="opacity:.7">— facultative</span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le nom de domaine ou l'IP par lesquels on atteint cette interface, par exemple <code>ia.exemple.fr</code> ou <code>192.168.1.20:8090</code>. <b>Laissez vide</b> et l'adresse affichée suit celle de votre navigateur. Renseignez-la si vous passez par un <b>reverse proxy</b> ou un nom de domaine : Loki ne voit alors qu'un appel interne et ne peut pas la deviner. Sans <code>https://</code>, il est ajouté.</span></span></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-public-url" readonly class="io-input">
|
||||
<button class="iconbtn" onclick="copyText(document.getElementById('oai-public-url').value,'adresse publique copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
|
||||
<input id="public-addr" class="io-input" placeholder="ia.exemple.fr" onkeydown="if(event.key==='Enter')savePublicAddr()">
|
||||
<button onclick="savePublicAddr()">enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé <b>redémarre</b> le service.</span></span></div>
|
||||
<div class="subhead">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions — vérifiée par Loki <b>et</b> par le moteur. Indispensable dès que l'adresse ci-dessus est joignable par d'autres. Modifier la clé <b>redémarre</b> le moteur.</span></span></div>
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div id="oai-open-warn" style="display:none;margin:0 0 8px;font-size:11.5px;line-height:1.5;color:var(--err)"></div>
|
||||
<div class="row" style="margin:0">
|
||||
<input id="oai-key" readonly class="io-input">
|
||||
<button id="oai-key-eye" onclick="toggleKeyReveal()" title="afficher / masquer">afficher</button>
|
||||
@@ -328,13 +346,36 @@
|
||||
<!-- Ligne 2 : bench + clear chat (seule action destructive, gardée en dernier). -->
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||||
<button onclick="resetChat()">⌫ clear chat</button>
|
||||
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
</div><!-- /sidegroup -->
|
||||
</details><!-- /#side-settings -->
|
||||
</div><!-- /#side-scroll -->
|
||||
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
|
||||
jauges fines — visible en permanence sans jamais disputer la place au
|
||||
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
|
||||
bloc entier se replie d'un clic. -->
|
||||
<div id="side-perf">
|
||||
<div class="perf-head"><span>Performance</span></div>
|
||||
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main" style="position:relative">
|
||||
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
|
||||
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
|
||||
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
|
||||
<header id="topbar">
|
||||
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||
</button>
|
||||
<div id="topbar-title">Nouvelle discussion</div>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div id="chat"></div>
|
||||
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
|
||||
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
|
||||
@@ -349,7 +390,7 @@
|
||||
pas de civilité ni d'accord qui obligerait à deviner. -->
|
||||
<div id="ce-title">On commence par quoi ?</div>
|
||||
</div>
|
||||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
|
||||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
|
||||
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
|
||||
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
|
||||
<div id="composer">
|
||||
@@ -361,18 +402,6 @@
|
||||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||
<span id="ctx-text" class="muted">0 / 0</span>
|
||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
|
||||
gabarit que le trombone — ce sont les deux faces du même sujet,
|
||||
ce qui entre et ce qui sort. -->
|
||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers du dossier de travail" aria-label="Fichiers du dossier de travail" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
</button>
|
||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
|
||||
dont le rendu varie énormément d'un système à l'autre. -->
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.4 11.05 12.25 20.2a5.5 5.5 0 0 1-7.78-7.78l9.19-9.19a3.67 3.67 0 1 1 5.18 5.18l-9.2 9.2a1.83 1.83 0 1 1-2.59-2.6l8.49-8.48"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
||||
@@ -380,27 +409,49 @@
|
||||
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
||||
<div id="attach-list"></div>
|
||||
<div id="inputbar">
|
||||
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
||||
<button id="send" onclick="send()">send</button>
|
||||
<button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
|
||||
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
||||
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
||||
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||
<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="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
||||
</button>
|
||||
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
||||
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
||||
ce qui sort. -->
|
||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
||||
<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="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
</button>
|
||||
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
||||
reste porté par aria-label et l'infobulle — l'action garde son nom
|
||||
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
||||
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
||||
</button>
|
||||
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fichiers : ce que l'agent a écrit dans son dossier de travail. Panneau de
|
||||
DROITE, symétrique des réglages à gauche, ouvert et fermé par le bouton
|
||||
dossier du pied de carte. Il ne se remplit qu'à l'ouverture (loadFiles) —
|
||||
un ReadDir à chaque chargement de page pour un panneau fermé serait payé
|
||||
par tout le monde et utile à personne. -->
|
||||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||
utile à personne. -->
|
||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||
<aside id="files" aria-label="Fichiers du dossier de travail">
|
||||
<aside id="files" aria-label="Fichiers de cette discussion">
|
||||
<div class="files-head">
|
||||
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
||||
complet reste porté par l'aria-label de la section. -->
|
||||
<span class="stitle">Fichiers</span>
|
||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
|
||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
|
||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<!-- Fil d'Ariane : le dossier de travail contient des sous-dossiers (captures
|
||||
par discussion), il faut pouvoir y descendre et en remonter. -->
|
||||
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
||||
captures), il faut pouvoir y descendre et en remonter. -->
|
||||
<div id="files-crumb" class="files-crumb"></div>
|
||||
<div id="files-list" class="files-list"></div>
|
||||
<div id="files-foot" class="files-foot muted"></div>
|
||||
@@ -410,7 +461,7 @@
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>Benchmark</strong>
|
||||
<button onclick="closeBenchModal()" style="margin:0">×</button>
|
||||
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||
@@ -427,7 +478,7 @@
|
||||
<div class="modal-card" style="max-width:460px">
|
||||
<div class="modal-head">
|
||||
<strong>Exporter la conversation</strong>
|
||||
<button class="modal-x" onclick="closeExportModal()" title="fermer">×</button>
|
||||
<button class="modal-x" onclick="closeExportModal()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<!-- Conversation vide : pas d'options à régler, un seul message. Proposer
|
||||
des réglages pour un fichier qui ne contiendrait rien n'a aucun sens. -->
|
||||
@@ -500,10 +551,10 @@
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
<strong>Backends personnalisés</strong>
|
||||
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button>
|
||||
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body mform">
|
||||
<div class="muted" style="font-size:12px;line-height:1.5">Compile un fork de llama.cpp depuis un dépôt Git — pour les modèles qui exigent un moteur spécial (quant ternaire, etc.). Un backend personnalisé <b>ne remplace pas</b> le moteur : vous le choisissez ensuite <b>par modèle</b> (bouton ✎ → section Moteur → « backend détecté »).</div>
|
||||
<div class="muted" style="font-size:12px;line-height:1.5">Compile un fork de llama.cpp depuis un dépôt Git — pour les modèles qui exigent un moteur spécial (quant ternaire, etc.). Un backend personnalisé <b>ne remplace pas</b> le moteur : vous le choisissez ensuite <b>par modèle</b> (bouton crayon → section Moteur → « backend détecté »).</div>
|
||||
<div class="fld">
|
||||
<label class="fld-l">Installés</label>
|
||||
<div id="lc-custom-list" class="mcp-list"></div>
|
||||
@@ -526,11 +577,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-catalog-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcpCatalog()">
|
||||
<div class="modal-card" style="max-width:640px">
|
||||
<div class="modal-head">
|
||||
<strong>Catalogue MCP</strong>
|
||||
<button class="modal-x" onclick="closeMcpCatalog()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="muted" style="font-size:12px;margin-bottom:8px">
|
||||
Serveurs connus, embarqués dans Loki — aucun appel réseau pour cette liste.
|
||||
« Utiliser » remplit le formulaire d'ajout : la commande s'exécutera sur
|
||||
cette machine, relis-la avant d'enregistrer.
|
||||
</div>
|
||||
<div id="mcp-catalog-tabs" class="mcp-cat-tabs"></div>
|
||||
<div id="mcp-catalog-list" class="mcp-cat-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
<strong id="mcp-modal-title">Serveur MCP</strong>
|
||||
<button class="modal-x" onclick="closeMcp()" aria-label="Fermer">×</button>
|
||||
<button class="modal-x" onclick="closeMcp()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body mform">
|
||||
<div class="fld">
|
||||
@@ -589,7 +657,7 @@
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<strong id="modal-title">Preset</strong>
|
||||
<button class="modal-x" onclick="closeModal()" aria-label="Fermer">×</button>
|
||||
<button class="modal-x" onclick="closeModal()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<!-- Chargement : la modale s'ouvre tout de suite, mais son contenu n'arrive
|
||||
qu'après (jusqu'à 3 requêtes en accès distant). Ce voile se pose PAR-DESSUS
|
||||
@@ -637,7 +705,7 @@
|
||||
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">ou un projecteur mmproj — lien direct vers un .gguf</span></span>
|
||||
<div class="pe-inpbtn">
|
||||
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
|
||||
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
|
||||
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger" aria-label="Télécharger"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="M7.5 10.5 12 15l4.5-4.5"/><path d="M4.5 19.5h15"/></svg></button>
|
||||
</div>
|
||||
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
|
||||
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
|
||||
@@ -773,6 +841,18 @@
|
||||
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row" id="s-reasoning-effort-row">
|
||||
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">envoyée au gabarit du modèle — auto = ne rien imposer</span></span>
|
||||
<span class="pe-row-c">
|
||||
<select id="s-reasoning-effort" class="pe-val" onchange="cfgWriteKey('REASONING_EFFORT', this.value)">
|
||||
<option value="">auto</option>
|
||||
<option value="none">aucune</option>
|
||||
<option value="low">basse</option>
|
||||
<option value="medium">moyenne</option>
|
||||
<option value="high">haute</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<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>
|
||||
|
||||
@@ -24,11 +24,14 @@ function initTheme(){
|
||||
// Quatre interrupteurs indépendants. Chacun pose un attribut sur <html> ; le CSS
|
||||
// fait le reste, sauf « replier les bulles » qui est un comportement (voir
|
||||
// 08-chat-render).
|
||||
//
|
||||
// « barre latérale escamotable » a disparu d'ici : la refonte l'escamote pour de
|
||||
// bon (bouton de l'en-tête, data-side — voir 18-shell.js), au lieu de la
|
||||
// transformer en tiroir qui recouvre la conversation.
|
||||
const VIEW_OPTS=[
|
||||
{id:'hide-reasoning', label:'masquer le raisonnement'},
|
||||
{id:'hide-tools', label:"masquer les appels d'outils"},
|
||||
{id:'fold-tools', label:'garder les bulles repliées'},
|
||||
{id:'hide-side', label:'barre latérale escamotable'},
|
||||
{id:'hide-stats', label:'masquer la vitesse de génération'},
|
||||
];
|
||||
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
|
||||
@@ -36,12 +39,6 @@ function applyView(id, on){
|
||||
document.documentElement.setAttribute('data-'+id, on?'1':'0');
|
||||
try{ localStorage.setItem('loki-'+id, on?'1':'0'); }catch(e){}
|
||||
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
|
||||
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
|
||||
if(id==='hide-side' && !on){
|
||||
document.getElementById('side').classList.remove('open');
|
||||
document.getElementById('backdrop').classList.remove('open');
|
||||
document.body.classList.remove('drawer-open');
|
||||
}
|
||||
}
|
||||
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
|
||||
function initView(){
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// ─── Jeu d'icônes ────────────────────────────────────────────────────────────
|
||||
// Un seul trait pour toute l'interface : 24 unités de côté, 1,8 d'épaisseur,
|
||||
// extrémités et jonctions arrondies — celui des deux icônes de la maquette
|
||||
// (dépôt et envoi), étendu au reste de l'application.
|
||||
//
|
||||
// Elles étaient jusqu'ici des GLYPHES DE TEXTE (↻ × ☰ 🗑 ✎ ↓ ▸). C'est le
|
||||
// système d'exploitation qui les dessinait : le même bouton sortait fin sur
|
||||
// macOS, gras et bleuté sur Windows (émojis en couleur), et carrément absent sur
|
||||
// un serveur sans police d'émojis. Aucune cohérence possible, et rien à régler
|
||||
// depuis la feuille de style. En SVG, elles suivent currentColor et la taille
|
||||
// qu'on leur donne.
|
||||
//
|
||||
// Ajouter une icône : une entrée ici, et rien d'autre — surtout pas un second
|
||||
// dessin ailleurs, c'est ainsi que le trait finit par diverger.
|
||||
const ICONS = {
|
||||
menu: '<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>',
|
||||
close: '<path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/>',
|
||||
refresh: '<path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/>',
|
||||
pencil: '<path d="M4 20h4L19 9a2.1 2.1 0 0 0-3-3L5 17z"/><path d="M14.5 7.5l3 3"/>',
|
||||
trash: '<path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/>',
|
||||
download: '<path d="M12 4v11"/><path d="M7.5 10.5 12 15l4.5-4.5"/><path d="M4.5 19.5h15"/>',
|
||||
arrowDown: '<path d="M12 5v14"/><path d="M6 13l6 6 6-6"/>',
|
||||
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
file: '<path d="M13.5 3.5H7A1.5 1.5 0 0 0 5.5 5v14A1.5 1.5 0 0 0 7 20.5h10a1.5 1.5 0 0 0 1.5-1.5V8.5z"/><path d="M13.5 3.5V8.5h5"/>',
|
||||
image: '<rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.4"/><path d="M4.5 17.5 10 12l3.5 3.5L16 13l3.5 3.5"/>',
|
||||
plus: '<path d="M12 5.5v13"/><path d="M5.5 12h13"/>',
|
||||
chevron: '<path d="M9.5 5.5 16 12l-6.5 6.5"/>',
|
||||
search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.4-4.4"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2"/><path d="M12 19.3v2.2"/><path d="M4.2 4.2l1.6 1.6"/><path d="M18.2 18.2l1.6 1.6"/><path d="M2.5 12h2.2"/><path d="M19.3 12h2.2"/><path d="M4.2 19.8l1.6-1.6"/><path d="M18.2 5.8l1.6-1.6"/>',
|
||||
};
|
||||
|
||||
// iconHTML rend l'icône en chaîne, pour les endroits qui composent déjà du
|
||||
// balisage. `aria-hidden` : le sens est porté par le title/aria-label du bouton,
|
||||
// jamais par le dessin.
|
||||
function iconHTML(name, size){
|
||||
const d = ICONS[name];
|
||||
if(!d) return '';
|
||||
const n = size || 18;
|
||||
return '<svg viewBox="0 0 24 24" width="' + n + '" height="' + n + '" fill="none" '
|
||||
+ 'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '
|
||||
+ 'stroke-linejoin="round" aria-hidden="true">' + d + '</svg>';
|
||||
}
|
||||
|
||||
// icon rend l'icône en ÉLÉMENT, pour les listes construites en DOM (fichiers,
|
||||
// discussions) où l'on ne veut pas d'innerHTML.
|
||||
function icon(name, size){
|
||||
const span = document.createElement('span');
|
||||
span.className = 'icn';
|
||||
span.innerHTML = iconHTML(name, size); // contenu constant, jamais de donnée
|
||||
return span.firstElementChild || span;
|
||||
}
|
||||
@@ -42,7 +42,9 @@ async function loadStatus(){
|
||||
function toggleSvcLog(){
|
||||
const box=document.getElementById('svc-log-box');
|
||||
if(!box) return;
|
||||
box.open = !box.open;
|
||||
// openDetails déplie aussi « Réglages » : sans lui, la section s'ouvrait à
|
||||
// l'intérieur d'un repli fermé — donc nulle part, du point de vue de l'écran.
|
||||
if(box.open) box.open = false; else openDetails(box);
|
||||
}
|
||||
async function loadSvcLog(){
|
||||
const el=document.getElementById('svc-log');
|
||||
@@ -127,30 +129,64 @@ function updateCtxMeter(){
|
||||
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
|
||||
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
|
||||
}
|
||||
// ─── Moniteur machine (pied de barre latérale) ───────────────────────────────
|
||||
// Une jauge par ressource : nom, pourcentage, barre, puis le détail chiffré en
|
||||
// dessous. C'est la hiérarchie de la maquette — on lit la charge d'un coup
|
||||
// d'œil, et le détail (Gio, température) reste là pour qui le cherche.
|
||||
//
|
||||
// Construit EN DOM et non en innerHTML : les noms de GPU viennent du pilote,
|
||||
// donc d'ailleurs. Même règle que pour les noms de fichiers et les titres de
|
||||
// discussions.
|
||||
function perfRow(label, pct, title){
|
||||
pct = Math.max(0, Math.min(100, Math.round(pct||0)));
|
||||
const row=document.createElement('div'); row.className='perf-row';
|
||||
if(title) row.title=title;
|
||||
const l=document.createElement('div'); l.className='perf-l';
|
||||
const b=document.createElement('b'); b.textContent=label;
|
||||
const v=document.createElement('span'); v.className='perf-pct'; v.textContent=pct+'%';
|
||||
l.append(b,v);
|
||||
const track=document.createElement('div'); track.className='perf-track';
|
||||
const fill=document.createElement('div');
|
||||
// Sauge jusqu'à 75 %, ambre au-delà, rouille à 90 : la couleur ne sert à rien
|
||||
// tant que la machine respire, elle doit sauter aux yeux quand elle sature.
|
||||
fill.className='perf-fill'+(pct>=90?' max':pct>=75?' hot':'');
|
||||
fill.style.width=pct+'%';
|
||||
track.appendChild(fill);
|
||||
row.append(l,track);
|
||||
return row;
|
||||
}
|
||||
async function loadVram(){
|
||||
const gpus=await jget('/api/vram');
|
||||
// Bloc de statistique : intitulé + valeur sur une ligne, jauge, détail dessous.
|
||||
// Même gabarit que la RAM (voir .stat dans le CSS) — le HTML libre d'avant
|
||||
// collait aux bords de la carte.
|
||||
document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{
|
||||
const pct=Math.round(g.used*100/g.total);
|
||||
return '<div class="stat"><div class="stat-h"><span class="stat-n">'+g.name+'</span>'+
|
||||
'<span class="stat-v">'+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB</span></div>'+
|
||||
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
|
||||
'<div class="stat-s">GPU '+g.util+' % · '+g.temp+' °C</div></div>';
|
||||
}).join('') || '<div class="stat"><span class="stat-s">(pas de GPU)</span></div>';
|
||||
const box=document.getElementById('vram');
|
||||
if(!box) return;
|
||||
box.textContent='';
|
||||
if(!gpus || !gpus.length){
|
||||
const d=document.createElement('div'); d.className='perf-sub'; d.textContent='(pas de GPU détecté)';
|
||||
box.appendChild(d);
|
||||
return;
|
||||
}
|
||||
// Deux mesures par carte, deux jauges — une seule barre ne peut pas porter la
|
||||
// charge du processeur ET le remplissage de la mémoire vidéo, et pour du LLM
|
||||
// c'est la VRAM qui décide si un modèle tient. Sur une machine à UNE carte on
|
||||
// retombe exactement sur les trois jauges de la maquette (GPU, VRAM, RAM).
|
||||
const many = gpus.length > 1;
|
||||
gpus.forEach((g,i)=>{
|
||||
const vram=(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' Gio';
|
||||
box.appendChild(perfRow('GPU '+i, g.util, g.name+' · '+vram+' · '+g.temp+' °C'));
|
||||
box.appendChild(perfRow(many ? 'VRAM '+i : 'VRAM', g.used*100/g.total,
|
||||
g.name+' · '+vram));
|
||||
});
|
||||
}
|
||||
async function loadRam(){
|
||||
const m=await jget('/api/ram');
|
||||
const box=document.getElementById('ram-details');
|
||||
if(!m || !m.total){ if(box) box.style.display='none'; return; }
|
||||
if(box) box.style.display='';
|
||||
const pct=Math.round(m.used*100/m.total);
|
||||
document.getElementById('ram').innerHTML =
|
||||
'<div class="stat"><div class="stat-h"><span class="stat-n">Mémoire vive</span>'+
|
||||
'<span class="stat-v">'+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB</span></div>'+
|
||||
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
|
||||
'<div class="stat-s">'+pct+' % utilisée</div></div>';
|
||||
if(!box) return;
|
||||
if(!m || !m.total){ box.style.display='none'; return; }
|
||||
box.style.display='';
|
||||
const host=document.getElementById('ram');
|
||||
host.textContent='';
|
||||
host.appendChild(perfRow('Mémoire vive', m.used*100/m.total,
|
||||
(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' Gio utilisés'));
|
||||
}
|
||||
async function loadCfg(){
|
||||
// /api/llamacpp en parallèle : il indique si le BIN de la config correspond au
|
||||
@@ -165,11 +201,20 @@ async function loadCfg(){
|
||||
// En conteneur, le moteur vient de l'image : le dire, plutôt que de le
|
||||
// ranger dans « personnalisé » qui laisse croire à un bricolage.
|
||||
if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image';
|
||||
// Moteur mis à jour depuis l'image officielle (Réglages → Moteur) : c'est la
|
||||
// voie normale en conteneur, pas un « personnalisé ».
|
||||
else if(lc && lc.engine && lc.engine.in_use){
|
||||
const b = (lc.engine.tag||'').match(/b\d+$/);
|
||||
v = b ? 'llama.cpp '+b[0]+' (mis à jour)' : 'llama.cpp mis à jour';
|
||||
}
|
||||
else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
||||
else if(lc && lc.in_use) v='llama.cpp compilé';
|
||||
else v='llama.cpp personnalisé : '+c.BIN;
|
||||
rows.push(row('MOTEUR', v, c.BIN));
|
||||
}
|
||||
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
|
||||
// source qui le connaisse quand aucun preset ne correspond à la configuration.
|
||||
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
|
||||
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
|
||||
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
|
||||
rows.push(row(k, v));
|
||||
|
||||
@@ -44,6 +44,8 @@ async function loadPresets(){
|
||||
const sp = document.getElementById('status-preset');
|
||||
sp.textContent = act ? act.name : '';
|
||||
sp.title = act ? 'preset actif' : '';
|
||||
// Sélecteur de modèle de l'en-tête : même donnée, autre point d'entrée.
|
||||
if(typeof renderModelSwitch === 'function') renderModelSwitch(p, act);
|
||||
const cont=document.getElementById('presets');
|
||||
cont.innerHTML='';
|
||||
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
|
||||
@@ -229,27 +231,39 @@ async function copyText(txt, msg){
|
||||
function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); }
|
||||
function renderApiKey(d){
|
||||
OAI_KEY = d.key || '';
|
||||
// URL de l'endpoint : llama-server tourne sur d.port (≠ port de l'UI web).
|
||||
// On prend l'hôte annoncé par le serveur (IP LAN détectée côté Go) : dans le
|
||||
// tunnel ajean.link, location.hostname serait le domaine du relais (faux) —
|
||||
// l'accès OpenAI reste TOUJOURS l'adresse locale de la machine.
|
||||
const host = d.host || location.hostname;
|
||||
document.getElementById('oai-url').value = 'http://'+host+':'+d.port+'/v1';
|
||||
// Endpoint PUBLIC (ajean.link) : affiché seulement si l'accès public est activé.
|
||||
const tg = document.getElementById('oai-public-toggle');
|
||||
if(tg) tg.checked = !!d.oai_public;
|
||||
const pubWrap = document.getElementById('oai-public-wrap');
|
||||
if(d.oai_public && d.machine){
|
||||
document.getElementById('oai-public-url').value = 'https://'+d.machine+'.oai.ajean.link/v1';
|
||||
pubWrap.style.display = '';
|
||||
} else {
|
||||
pubWrap.style.display = 'none';
|
||||
// L'adresse vient du SERVEUR (web_public_url.go) : adresse publique
|
||||
// enregistrée, sinon l'origine par laquelle cette page a été ouverte. Elle
|
||||
// était auparavant recomposée ici — 'http://'+host+':'+port+'/v1' — à partir
|
||||
// du port du MOTEUR et d'une IP détectée côté Go, ce qui donnait en conteneur
|
||||
// une adresse que personne ne pouvait joindre (bridge Docker, port non publié).
|
||||
// location.origin ne sert plus que de filet si le serveur ne dit rien.
|
||||
document.getElementById('oai-url').value = d.url || (location.origin + '/v1');
|
||||
// Champ d'adresse publique : on ne l'écrase pas pendant la frappe.
|
||||
const pa = document.getElementById('public-addr');
|
||||
if(pa && document.activeElement !== pa) pa.value = d.public_url || '';
|
||||
// Sans clé, l'endpoint est ouvert — et il l'est maintenant partout où
|
||||
// l'interface l'est. Ça ne se dit pas à voix basse.
|
||||
const warn = document.getElementById('oai-open-warn');
|
||||
if(warn){
|
||||
warn.style.display = d.set ? 'none' : '';
|
||||
warn.textContent = d.set ? '' :
|
||||
'⚠ Aucune clé : toute personne capable de joindre cette adresse peut utiliser votre modèle.';
|
||||
}
|
||||
const inp=document.getElementById('oai-key');
|
||||
if(!d.set){ inp.value='(aucune clé — serveur ouvert)'; inp.style.opacity=.6; }
|
||||
if(!d.set){ inp.value='(aucune clé — endpoint ouvert)'; inp.style.opacity=.6; }
|
||||
else { inp.style.opacity=1; inp.value = OAI_REVEAL ? OAI_KEY : d.masked; }
|
||||
document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none';
|
||||
}
|
||||
// Enregistre l'adresse publique. Le serveur normalise et refuse en clair : on
|
||||
// relaie sa raison telle quelle, elle est écrite pour être lue.
|
||||
async function savePublicAddr(){
|
||||
const inp = document.getElementById('public-addr');
|
||||
if(!inp) return;
|
||||
const r = await jpost('/api/oai/public', {url: inp.value});
|
||||
if(!r || !r.ok){ toast('adresse refusée : ' + ((r && r.error) || '')); return; }
|
||||
toast(r.url ? 'adresse enregistrée' : 'adresse effacée — celle du navigateur sera utilisée');
|
||||
loadApiKey();
|
||||
}
|
||||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||||
// --- Export de la conversation ---------------------------------------------
|
||||
// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de
|
||||
@@ -381,50 +395,11 @@ async function downloadExport(url){
|
||||
toast('exporté : ' + name);
|
||||
}catch(e){ toast('erreur : ' + e.message); }
|
||||
}
|
||||
// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------
|
||||
// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le
|
||||
// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le
|
||||
// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part.
|
||||
function renderNetwork(st){
|
||||
if(!st) return;
|
||||
const cb = document.getElementById('lan-toggle');
|
||||
if(cb) cb.checked = !!st.exposed;
|
||||
const warn = document.getElementById('lan-warn');
|
||||
if(!warn) return;
|
||||
if(!st.exposed){
|
||||
warn.style.display = '';
|
||||
warn.innerHTML = '<div class="muted" style="margin:0">Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.</div>';
|
||||
return;
|
||||
}
|
||||
// Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute
|
||||
// partout » et pourtant rien ne passe). On donne la commande à coller.
|
||||
if(st.hint){
|
||||
warn.style.display = '';
|
||||
// --err et non --warn : la palette est volontairement monochrome et --warn y
|
||||
// est un gris, illisible comme alerte. Ici il y a une vraie action à faire.
|
||||
warn.innerHTML = '<div style="margin:0;color:var(--err)">⚠ '
|
||||
+ escHtml(st.hint).replace(/\n/g,'<br>') + '</div>';
|
||||
return;
|
||||
}
|
||||
warn.style.display = 'none';
|
||||
}
|
||||
async function loadNetwork(){
|
||||
try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){}
|
||||
}
|
||||
async function toggleLAN(){
|
||||
const cb = document.getElementById('lan-toggle');
|
||||
const on = cb.checked;
|
||||
const r = await jpost('/api/network', {exposed:on});
|
||||
if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; }
|
||||
renderNetwork(r.status);
|
||||
// llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur
|
||||
// affiche un état que le moteur en cours ne respecte pas encore.
|
||||
if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).',
|
||||
{title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){
|
||||
await act('restart'); // même chemin que les boutons du panneau Moteur
|
||||
}
|
||||
loadApiKey();
|
||||
}
|
||||
// L'écoute réseau du moteur (HOST + pare-feu) n'a plus d'interrupteur : depuis
|
||||
// que Loki sert /v1 sur son propre port, ouvrir le moteur sur le réseau n'a plus
|
||||
// d'objet — et deux interrupteurs pour « rendre l'IA joignable » était
|
||||
// exactement la confusion qu'on voulait lever. La route /api/network et la
|
||||
// commande `loki network` restent, pour qui veut exposer le moteur en direct.
|
||||
function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; }
|
||||
async function apiKeyAction(action){
|
||||
if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return;
|
||||
@@ -433,24 +408,6 @@ async function apiKeyAction(action){
|
||||
toast('application…');
|
||||
renderApiKey(await jpost('/api/apikey', {action}));
|
||||
}
|
||||
async function toggleOAIPublic(){
|
||||
const cb = document.getElementById('oai-public-toggle');
|
||||
const on = cb.checked;
|
||||
// L'accès OpenAI public passe par ajean.link (<machine>.oai.ajean.link) : il exige
|
||||
// que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique.
|
||||
if(on){
|
||||
let linked = false;
|
||||
try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){}
|
||||
if(!linked){
|
||||
cb.checked = false;
|
||||
await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await jpost('/api/oai/public', {enabled:on});
|
||||
toast(on ? 'accès public activé' : 'accès public coupé');
|
||||
loadApiKey();
|
||||
}
|
||||
async function apiKeySet(){
|
||||
const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'});
|
||||
if(!k || !k.trim()) return;
|
||||
@@ -494,7 +451,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -746,6 +746,20 @@ function populateSettings(){
|
||||
rzSub.textContent = rz ? 'réflexion étape par étape'
|
||||
: 'réflexion étape par étape — non précisé : le modèle décide';
|
||||
}
|
||||
// Intensité : passée telle quelle au gabarit jinja du modèle via
|
||||
// `reasoning_effort`. Seuls les gabarits qui la lisent (gpt-oss et apparentés)
|
||||
// changent de comportement ; ailleurs elle est ignorée sans erreur — le
|
||||
// sous-titre le dit pour ne pas laisser croire à un effet garanti.
|
||||
const eff = document.getElementById('s-reasoning-effort');
|
||||
if(eff){
|
||||
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
|
||||
const effSub = document.getElementById('s-reasoning-effort-sub');
|
||||
if(effSub){
|
||||
effSub.textContent = eff.value
|
||||
? 'envoyée au gabarit du modèle — sans effet si son gabarit l\'ignore'
|
||||
: 'envoyée au gabarit du modèle — auto = ne rien imposer';
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mlock', eaHasFlag('--mlock'));
|
||||
chk('s-nommap', eaHasFlag('--no-mmap'));
|
||||
|
||||
@@ -327,9 +327,18 @@ function addCopyButtons(root){
|
||||
pre.appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Nouvelle conversation POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||||
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||||
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
|
||||
function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); toast('nouvelle conversation'); }
|
||||
//
|
||||
// Confirmé, car le geste emporte aussi les FICHIERS de la discussion (dépôts,
|
||||
// captures, ce que l'agent y a écrit) : ils n'appartiennent qu'à elle, et plus
|
||||
// un message ne les mentionnerait.
|
||||
async function resetChat(){
|
||||
if(!await askConfirm('Le fil de cette discussion et ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront effacés.\n\nC\'est définitif.',
|
||||
{title:'Vider la discussion', okText:'Vider', danger:true})) return;
|
||||
jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{});
|
||||
toast('discussion vidée');
|
||||
}
|
||||
// Compaction : on demande à l'IA un résumé de la conversation destiné à la
|
||||
// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
|
||||
// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
|
||||
|
||||
@@ -22,7 +22,10 @@ function addPending(text){
|
||||
clearPending();
|
||||
PENDING=addMsg('user', text);
|
||||
PENDING.classList.add('pending');
|
||||
const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…';
|
||||
// L'étiquette garde l'avatar et le prénom (paintLabel, posé par addMsg) : une
|
||||
// bulle ne doit JAMAIS s'appeler autrement d'un tour à l'autre. L'envoi en
|
||||
// cours se lit à la bulle grisée (.pending) et à l'infobulle.
|
||||
const l=PENDING.querySelector('.label'); if(l) l.title='envoi en cours…';
|
||||
jumpBottom();
|
||||
return PENDING;
|
||||
}
|
||||
@@ -33,7 +36,11 @@ function confirmPending(text){
|
||||
const b=PENDING.querySelector('.body');
|
||||
if(!b || b.textContent!==text) return false;
|
||||
PENDING.classList.remove('pending');
|
||||
const l=PENDING.querySelector('.label'); if(l) l.textContent='user';
|
||||
// ⚠️ NE PAS réécrire l'étiquette ici : elle porte l'avatar et le prénom
|
||||
// (17-identity.js). Elle y était remise à « user », si bien que le message
|
||||
// qu'on venait d'envoyer s'affichait « USER » alors que ceux rejoués au
|
||||
// chargement portaient le prénom — deux bulles identiques, deux libellés.
|
||||
const l=PENDING.querySelector('.label'); if(l) l.removeAttribute('title');
|
||||
PENDING=null;
|
||||
return true;
|
||||
}
|
||||
@@ -114,8 +121,14 @@ function renderStats(el, s){
|
||||
function labelTokens(el, role, n, firstTs, lastTs){
|
||||
if(!el) return;
|
||||
const secs=(lastTs-firstTs)/1000;
|
||||
if(secs>0.05 && n>1){ setLabel(el, role+' · '+n+' tok · '+(n/secs).toFixed(1)+' tok/s'); }
|
||||
else { setLabel(el, role+' · '+n+' tok'); }
|
||||
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
|
||||
// Bulle technique : son étiquette EST le bouton de repli, les mesures y vont.
|
||||
// Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures
|
||||
// vont dans la ligne dédiée — comme le font déjà les stats de fin de tour
|
||||
// (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct
|
||||
// par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement.
|
||||
if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m);
|
||||
else setStats(el, m);
|
||||
}
|
||||
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
|
||||
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
|
||||
@@ -123,8 +136,12 @@ function labelTokens(el, role, n, firstTs, lastTs){
|
||||
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
|
||||
function syncSendBtn(){
|
||||
const sb=document.getElementById('send');
|
||||
sb.style.display=busy?'none':'inline-block';
|
||||
document.getElementById('stop').style.display=busy?'inline-block':'none';
|
||||
// ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux
|
||||
// boutons étaient montrés/cachés avec `style.display='inline-block'`, qui
|
||||
// l'emportait sur le `display:flex` de la feuille : le bouton gardait sa
|
||||
// boîte de 34 px mais son icône de 18 px se collait à gauche, décalée de 8 px.
|
||||
// C'est invisible tant que l'icône est un glyphe de texte, flagrant en SVG.
|
||||
document.documentElement.setAttribute('data-busy', busy ? '1' : '0');
|
||||
// Tant que le moteur n'a pas fini de charger le modèle, envoyer ne mène à rien :
|
||||
// on bloque le bouton et l'Entrée, et on le DIT sous le champ. `STATUS_SEEN`
|
||||
// évite de verrouiller le chat quand /api/status n'a pas encore répondu (ou ne
|
||||
@@ -154,8 +171,9 @@ function handleDelta(d){
|
||||
return; }
|
||||
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté
|
||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||
// a pu être déclenchée depuis un autre appareil.
|
||||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; }
|
||||
// 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){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
|
||||
if(d.user!==undefined){
|
||||
newTurn();
|
||||
let el=PENDING;
|
||||
|
||||
@@ -28,13 +28,13 @@ async function loadLlamacpp(){
|
||||
|
||||
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
|
||||
if(s.job && s.job.exists && s.job.running && !lcPoll){
|
||||
document.getElementById('lc-details').open = true;
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
// Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
|
||||
// typiquement quand le service a redémarré) : on l'affiche sans polling.
|
||||
else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
|
||||
document.getElementById('lc-details').open = true;
|
||||
openDetails('lc-details');
|
||||
document.getElementById('lc-job').style.display = '';
|
||||
lcLogNext = 0;
|
||||
document.getElementById('lc-log').textContent = '';
|
||||
@@ -56,7 +56,8 @@ async function loadLlamacpp(){
|
||||
let lcSeenEnd = false, lcEndShown = false;
|
||||
function lcChipLabel(action){
|
||||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||||
prebuilt:'Téléchargement du moteur', custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
|
||||
custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
}
|
||||
function lcChipSync(j){
|
||||
const chip = document.getElementById('lc-chip');
|
||||
@@ -73,7 +74,7 @@ function lcChipOpen(){
|
||||
const side = document.getElementById('side');
|
||||
if(!side.classList.contains('open')) toggleSide();
|
||||
const det = document.getElementById('lc-details');
|
||||
det.open = true;
|
||||
openDetails(det); // déplie aussi « Réglages », qui contient la section
|
||||
det.scrollIntoView({block:'center'});
|
||||
if(!document.getElementById('lc-chip').classList.contains('failed')) return;
|
||||
lcSeenEnd = true;
|
||||
@@ -97,7 +98,7 @@ function lcRenderReco(reco){
|
||||
if(desc && reco && reco.why) desc.textContent = reco.why;
|
||||
}
|
||||
|
||||
// Bascule entre « moteur de l'image » (une ligne d'état) et les trois modes
|
||||
// Bascule entre la carte « moteur de l'image » et les trois modes
|
||||
// d'installation gérés par Loki.
|
||||
function lcRenderProvided(s){
|
||||
const box = document.getElementById('lc-provided');
|
||||
@@ -108,8 +109,87 @@ function lcRenderProvided(s){
|
||||
if(!s.provided) return;
|
||||
const bin = document.getElementById('lc-provided-bin');
|
||||
if(bin) bin.textContent = s.config_bin || '';
|
||||
const job = document.getElementById('lc-job');
|
||||
if(job) job.style.display = 'none';
|
||||
loadEngine();
|
||||
// Un job de mise à jour du moteur peut être en cours : le bloc de progression
|
||||
// était masqué ici tant que la carte ne servait qu'à dire « rien à faire ».
|
||||
if(s.job && s.job.exists && s.job.running && !lcPoll){
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
lcChipSync(s.job);
|
||||
}
|
||||
|
||||
// --- Moteur de l'image : le mettre à jour sans reconstruire l'image ---------
|
||||
// llama.cpp publie plusieurs versions par jour ; son moteur déjà compilé vit
|
||||
// dans l'image officielle, dont on n'extrait que /app (~170 Mo). Voir
|
||||
// web_engine.go pour le détail, et pourquoi ce n'est pas une release GitHub :
|
||||
// llama.cpp n'en publie aucune pour CUDA/Linux.
|
||||
let engState = null;
|
||||
|
||||
async function loadEngine(){
|
||||
let s;
|
||||
try{ s = await jget('/api/engine'); }catch(_){ return; }
|
||||
engState = s;
|
||||
const build = document.getElementById('eng-build');
|
||||
if(build) build.textContent = s.build ? ('b'+s.build+(s.commit ? ' · '+s.commit : '')) : 'version inconnue';
|
||||
const src = document.getElementById('eng-source');
|
||||
if(src) src.textContent = {image:'fourni par l\'image', downloaded:'mis à jour par Loki',
|
||||
prebuilt:'précompilé', custom:'personnalisé'}[s.source] || '';
|
||||
const repo = document.getElementById('eng-repo');
|
||||
if(repo) repo.textContent = s.repo + ':' + (s.variant || 'server');
|
||||
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
|
||||
const revert = document.getElementById('eng-revert');
|
||||
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
|
||||
engSay('');
|
||||
}
|
||||
|
||||
// engSay écrit sous la ligne d'état : c'est le résultat d'une vérification, qui
|
||||
// doit rester lisible après le toast (on ne se souvient pas d'un toast).
|
||||
function engSay(html, cls){
|
||||
const el = document.getElementById('eng-latest');
|
||||
if(!el) return;
|
||||
el.innerHTML = html;
|
||||
el.className = cls || 'muted';
|
||||
el.style.display = html ? '' : 'none';
|
||||
}
|
||||
|
||||
async function engCheck(){
|
||||
toast('vérification…');
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/check', {}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ engSay('✗ '+String(r.error||'').replace(/[<>&]/g,'')); toast('erreur'); return; }
|
||||
if(r.update){
|
||||
engSay('Version disponible : <b>b'+r.latest+'</b>'+(r.current ? ' (installée : b'+r.current+')' : '')
|
||||
+ ' — « mettre à jour le moteur » pour l\'installer.');
|
||||
toast('mise à jour disponible : b'+r.latest);
|
||||
} else {
|
||||
engSay('Moteur à jour ✓ (b'+r.latest+')');
|
||||
toast('moteur à jour ✓');
|
||||
}
|
||||
}
|
||||
|
||||
async function engUpdate(){
|
||||
if(!await askConfirm('Télécharger la dernière version de llama.cpp (~170 Mo) et l\'utiliser à la place du moteur de l\'image.\n\n'
|
||||
+ 'Le moteur est essayé avant d\'être adopté ; celui de l\'image reste intact. Le moteur redémarre à la fin — la génération en cours sera coupée.',
|
||||
{title:'Mettre à jour le moteur', okText:'Mettre à jour'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/update', {}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
|
||||
// Bascule vers une version déjà installée — « image » = celle d'origine.
|
||||
async function engUse(tag){
|
||||
const image = tag === 'image';
|
||||
if(!await askConfirm(image ? 'Repasser sur le moteur livré avec l\'image Docker. Le moteur redémarre.'
|
||||
: 'Utiliser la version « '+tag+' ». Le moteur redémarre.',
|
||||
{title:'Changer de moteur', okText:'Basculer'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/use', {tag}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
toast('moteur basculé — redémarrage en cours');
|
||||
loadAll();
|
||||
}
|
||||
|
||||
function lcRenderMode(mode, installed){
|
||||
@@ -235,7 +315,7 @@ async function lcInstallCustom(){
|
||||
const r = await jpost('/api/llamacpp/install-custom', {repo:url, name});
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
closeCustomBackends();
|
||||
document.getElementById('lc-details').open = true;
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ function renderMCP(r){
|
||||
sw.appendChild(cb); sw.appendChild(sl);
|
||||
|
||||
const edit = document.createElement('button');
|
||||
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
|
||||
edit.className = 'mcp-edit'; edit.title = 'éditer';
|
||||
edit.setAttribute('aria-label', 'éditer ce serveur'); edit.appendChild(icon('pencil', 15));
|
||||
edit.onclick = (e)=>{ e.stopPropagation(); openMcp(s.name); };
|
||||
|
||||
row.appendChild(dot); row.appendChild(info); row.appendChild(sw); row.appendChild(edit);
|
||||
@@ -77,6 +78,118 @@ async function toggleMcp(name, on){
|
||||
toast(on ? 'serveur activé' : 'serveur désactivé');
|
||||
}
|
||||
|
||||
// ─── Catalogue ───────────────────────────────────────────────────────────────
|
||||
// Liste embarquée de serveurs connus, servie par /api/mcp/catalog. Choisir une
|
||||
// entrée n'installe RIEN : ça ouvre la modale d'ajout préremplie, l'utilisateur
|
||||
// relit la commande (qui s'exécutera sur cette machine) puis enregistre.
|
||||
let mcpCatalog = [];
|
||||
let mcpCatalogFilter = 'Tous';
|
||||
|
||||
async function openMcpCatalog(){
|
||||
showModal('mcp-catalog-modal');
|
||||
const list = document.getElementById('mcp-catalog-list');
|
||||
if(!mcpCatalog.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">chargement…</div>';
|
||||
const r = await jget('/api/mcp/catalog');
|
||||
mcpCatalog = (r && r.catalog) || [];
|
||||
}
|
||||
renderMcpCatalog();
|
||||
}
|
||||
|
||||
function closeMcpCatalog(){ hideModal('mcp-catalog-modal'); }
|
||||
|
||||
function renderMcpCatalog(){
|
||||
const list = document.getElementById('mcp-catalog-list');
|
||||
const tabs = document.getElementById('mcp-catalog-tabs');
|
||||
list.textContent = '';
|
||||
tabs.textContent = '';
|
||||
|
||||
if(!mcpCatalog.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">(catalogue vide)</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cats = ['Tous'].concat([...new Set(mcpCatalog.map(e=>e.category))].sort());
|
||||
cats.forEach(c=>{
|
||||
const b = document.createElement('button');
|
||||
b.className = 'mcp-cat'+(c===mcpCatalogFilter?' on':'');
|
||||
b.textContent = c;
|
||||
b.onclick = ()=>{ mcpCatalogFilter = c; renderMcpCatalog(); };
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
|
||||
mcpCatalog
|
||||
.filter(e=>mcpCatalogFilter==='Tous' || e.category===mcpCatalogFilter)
|
||||
.forEach(e=>{
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mcp-cat-row';
|
||||
|
||||
const info = document.createElement('div'); info.className = 'mcp-info';
|
||||
const nm = document.createElement('div'); nm.className = 'mcp-name';
|
||||
nm.textContent = e.name;
|
||||
if(e.installed){
|
||||
const tag = document.createElement('span'); tag.className = 'mcp-tag';
|
||||
tag.textContent = 'installé'; tag.style.marginLeft = '6px';
|
||||
nm.appendChild(tag);
|
||||
}
|
||||
const desc = document.createElement('div'); desc.className = 'mcp-cat-desc';
|
||||
desc.textContent = e.description;
|
||||
const meta = document.createElement('div'); meta.className = 'mcp-meta';
|
||||
const cmd = document.createElement('code'); cmd.className = 'mcp-cat-cmd';
|
||||
cmd.textContent = [e.command].concat(e.args||[]).join(' ');
|
||||
cmd.title = cmd.textContent;
|
||||
meta.appendChild(cmd);
|
||||
if(!e.runtimeOk){
|
||||
const w = document.createElement('span'); w.className = 'mcp-err';
|
||||
w.textContent = 'runtime '+(e.runtime||'?')+' absent';
|
||||
w.title = e.runtime==='python'
|
||||
? "uv/uvx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer."
|
||||
: "npx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer.";
|
||||
meta.appendChild(w);
|
||||
}
|
||||
if((e.secrets||[]).length){
|
||||
const s = document.createElement('span'); s.className = 'mcp-tag';
|
||||
s.textContent = 'clé requise'; s.title = e.secrets.join(', ');
|
||||
meta.appendChild(s);
|
||||
}
|
||||
info.appendChild(nm); info.appendChild(desc); info.appendChild(meta);
|
||||
|
||||
const use = document.createElement('button');
|
||||
use.className = 'addbtn mcp-cat-use';
|
||||
use.textContent = e.installed ? 'revoir' : 'utiliser';
|
||||
use.onclick = ()=>{ closeMcpCatalog(); openMcpFromCatalog(e); };
|
||||
|
||||
row.appendChild(info); row.appendChild(use);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Préremplit la modale d'ajout à partir d'une entrée du catalogue. Le nom du
|
||||
// serveur = l'id de l'entrée, pour que « déjà installé » se détecte ensuite.
|
||||
function openMcpFromCatalog(e){
|
||||
openMcp(mcpServers.some(s=>s.name===e.id) ? e.id : '');
|
||||
if(!mcpServers.some(s=>s.name===e.id)){
|
||||
document.getElementById('mcp-name').value = e.id;
|
||||
document.querySelector('input[name="mcp-transport"][value="stdio"]').checked = true;
|
||||
mcpTransportUI('stdio');
|
||||
document.getElementById('mcp-command').value = e.command || '';
|
||||
document.getElementById('mcp-args').value = (e.args||[]).join('\n');
|
||||
document.getElementById('mcp-env').value =
|
||||
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
|
||||
document.getElementById('mcp-url').value = '';
|
||||
document.getElementById('mcp-headers').value = '';
|
||||
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
|
||||
}
|
||||
const st = document.getElementById('mcp-modal-status');
|
||||
if((e.secrets||[]).length){
|
||||
st.textContent = 'renseigne '+e.secrets.join(', ')+' avant d\'enregistrer';
|
||||
st.style.color = 'var(--err)';
|
||||
} else if(!e.runtimeOk){
|
||||
st.textContent = '⚠ runtime '+(e.runtime||'?')+' absent : le serveur ne démarrera pas ici';
|
||||
st.style.color = 'var(--err)';
|
||||
}
|
||||
}
|
||||
|
||||
// Ouvre la modale d'ajout (name='') ou d'édition (nom existant).
|
||||
function openMcp(name){
|
||||
mcpEditing = name || '';
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// ─── Fichiers du dossier de travail ──────────────────────────────────────────
|
||||
// ─── Fichiers de la discussion ───────────────────────────────────────────────
|
||||
// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici
|
||||
// on ne pouvait récupérer un fichier que si le modèle avait pensé à en mettre le
|
||||
// lien dans sa réponse — tout le reste restait invisible, et faire le ménage
|
||||
// demandait un shell.
|
||||
//
|
||||
// Le panneau montre les fichiers de la discussion OUVERTE, et rien d'autre :
|
||||
// chaque discussion a son dossier côté serveur (chat_convfiles.go), changer de
|
||||
// discussion change donc la liste, et supprimer une discussion emporte ses
|
||||
// fichiers. `FILES_SCOPE = 'legacy'` ouvre le détour « hors discussion » : les
|
||||
// fichiers d'avant ce rangement, que la migration n'a pas su rattacher.
|
||||
//
|
||||
// Le panneau est construit EN DOM, jamais en innerHTML : les noms de fichiers
|
||||
// viennent du modèle et du système de fichiers, donc d'ailleurs. Même règle que
|
||||
// pour les titres de discussions (16-conversations.js).
|
||||
@@ -11,7 +17,8 @@
|
||||
// Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
|
||||
// déjà gérer le blob et les cas particuliers du navigateur.
|
||||
|
||||
let FILES_DIR = ''; // dossier affiché, relatif au dossier de travail
|
||||
let FILES_DIR = ''; // dossier affiché, relatif à la racine du panneau
|
||||
let FILES_SCOPE = ''; // '' = discussion ouverte, 'legacy' = fichiers hors discussion
|
||||
let FILES_OPEN = false;
|
||||
|
||||
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde
|
||||
@@ -44,7 +51,7 @@ async function loadFiles(dir){
|
||||
const foot = document.getElementById('files-foot');
|
||||
if(!list) return;
|
||||
let r;
|
||||
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)); }
|
||||
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)+'&scope='+encodeURIComponent(FILES_SCOPE)); }
|
||||
catch(_){ filesMsg('lecture impossible'); return; }
|
||||
if(!r.ok){
|
||||
// Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre
|
||||
@@ -58,13 +65,40 @@ async function loadFiles(dir){
|
||||
if(!r.entries.length){
|
||||
const d = document.createElement('div');
|
||||
d.className = 'files-empty muted';
|
||||
d.textContent = FILES_DIR ? '(dossier vide)' : "L'agent n'a encore rien écrit ici.";
|
||||
d.textContent = FILES_DIR ? '(dossier vide)'
|
||||
: (FILES_SCOPE === 'legacy' ? '(plus rien hors discussion)'
|
||||
: "Rien dans cette discussion — les fichiers déposés et ceux que l'agent écrit apparaissent ici.");
|
||||
list.appendChild(d);
|
||||
}
|
||||
for(const e of r.entries) list.appendChild(fileRow(e));
|
||||
|
||||
foot.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
|
||||
renderFoot(foot, r);
|
||||
}
|
||||
|
||||
// Pied : occupation disque de ce qui est affiché — la discussion ouverte, ou le
|
||||
// hors-discussion — et le va-et-vient entre les deux. Le bouton n'apparaît que
|
||||
// s'il reste vraiment quelque chose hors discussion (compté par le serveur), et
|
||||
// disparaît de lui-même une fois le ménage fait.
|
||||
function renderFoot(foot, r){
|
||||
if(!foot) return;
|
||||
foot.textContent = '';
|
||||
const n = document.createElement('span');
|
||||
n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
|
||||
foot.appendChild(n);
|
||||
foot.title = r.root || '';
|
||||
if(FILES_SCOPE !== 'legacy' && !r.legacy) return;
|
||||
const b = document.createElement('button');
|
||||
b.className = 'files-scope';
|
||||
if(FILES_SCOPE === 'legacy'){
|
||||
b.textContent = '← cette discussion';
|
||||
b.title = 'revenir aux fichiers de la discussion ouverte';
|
||||
b.onclick = ()=>{ FILES_SCOPE = ''; loadFiles(''); };
|
||||
} else {
|
||||
b.textContent = 'hors discussion (' + r.legacy + ')';
|
||||
b.title = "fichiers d'avant le rangement par discussion";
|
||||
b.onclick = ()=>{ FILES_SCOPE = 'legacy'; loadFiles(''); };
|
||||
}
|
||||
foot.appendChild(b);
|
||||
}
|
||||
|
||||
function filesMsg(txt){
|
||||
@@ -76,7 +110,9 @@ function filesMsg(txt){
|
||||
list.appendChild(d);
|
||||
}
|
||||
|
||||
// Fil d'Ariane : « travail / captures / <id> », chaque segment cliquable.
|
||||
// Fil d'Ariane : « discussion / captures », chaque segment cliquable. La racine
|
||||
// est le dossier de la discussion ouverte — on n'en sort pas, sauf par le
|
||||
// détour « hors discussion », qui a sa propre racine.
|
||||
function renderCrumb(r){
|
||||
const c = document.getElementById('files-crumb');
|
||||
c.textContent = '';
|
||||
@@ -89,7 +125,7 @@ function renderCrumb(r){
|
||||
c.appendChild(b);
|
||||
};
|
||||
const parts = r.dir ? r.dir.split('/') : [];
|
||||
seg('travail', '', parts.length === 0);
|
||||
seg(FILES_SCOPE === 'legacy' ? 'hors discussion' : 'discussion', '', parts.length === 0);
|
||||
let acc = '';
|
||||
parts.forEach((p, i)=>{
|
||||
acc = acc ? acc + '/' + p : p;
|
||||
@@ -106,7 +142,7 @@ function fileRow(e){
|
||||
|
||||
const ic = document.createElement('span');
|
||||
ic.className = 'files-ic';
|
||||
ic.textContent = e.dir ? '▸' : (e.image ? '▣' : '·');
|
||||
ic.appendChild(icon(e.dir ? 'folder' : (e.image ? 'image' : 'file'), 16));
|
||||
row.appendChild(ic);
|
||||
|
||||
const mid = document.createElement('span');
|
||||
@@ -135,12 +171,14 @@ function fileRow(e){
|
||||
acts.className = 'files-acts';
|
||||
if(!e.dir){
|
||||
const dl = document.createElement('button');
|
||||
dl.className = 'iconbtn'; dl.textContent = '↓'; dl.title = 'télécharger';
|
||||
dl.className = 'iconbtn'; dl.appendChild(icon('download', 16));
|
||||
dl.title = 'télécharger'; dl.setAttribute('aria-label', 'télécharger');
|
||||
dl.onclick = ev=>{ ev.stopPropagation(); downloadWorkspaceFile(e.path, e.name, row); };
|
||||
acts.appendChild(dl);
|
||||
}
|
||||
const rm = document.createElement('button');
|
||||
rm.className = 'iconbtn'; rm.textContent = '🗑'; rm.title = 'supprimer';
|
||||
rm.className = 'iconbtn'; rm.appendChild(icon('trash', 16));
|
||||
rm.title = 'supprimer'; rm.setAttribute('aria-label', 'supprimer');
|
||||
rm.onclick = ev=>{ ev.stopPropagation(); removeFile(e); };
|
||||
acts.appendChild(rm);
|
||||
row.appendChild(acts);
|
||||
@@ -155,12 +193,21 @@ async function removeFile(e){
|
||||
: '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
|
||||
if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
|
||||
{title:'Supprimer ?', okText:'Supprimer', danger:true})) return;
|
||||
const r = await jpost('/api/chat/file/delete', {path: e.path});
|
||||
const r = await jpost('/api/chat/file/delete', {path: e.path, scope: FILES_SCOPE});
|
||||
if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
|
||||
toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// Changement de discussion (bascule, « vider »), signalé par le flux SSE :
|
||||
// le panneau montre les fichiers de la discussion OUVERTE, il doit donc être
|
||||
// redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui
|
||||
// de l'autre discussion, il n'existe pas forcément ici.
|
||||
function filesOnConvChange(){
|
||||
FILES_DIR = ''; FILES_SCOPE = '';
|
||||
if(FILES_OPEN) loadFiles('');
|
||||
}
|
||||
|
||||
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
|
||||
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
|
||||
document.addEventListener('DOMContentLoaded', initFiles);
|
||||
|
||||
@@ -8,43 +8,83 @@
|
||||
// Les titres viennent du premier message de l'utilisateur : ils sont construits
|
||||
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
|
||||
// balisage ni les gestionnaires d'événement.
|
||||
//
|
||||
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
|
||||
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
|
||||
// titre, dates, nombre d'échanges — jamais les messages.
|
||||
|
||||
let CONVS = [], CONV_ACTIVE = '';
|
||||
|
||||
async function loadConversations(){
|
||||
let r;
|
||||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||
CONVS = r.conversations || [];
|
||||
CONV_ACTIVE = r.active || '';
|
||||
renderConversations();
|
||||
}
|
||||
|
||||
function renderConversations(){
|
||||
const cont = document.getElementById('conv-list');
|
||||
if(!cont) return;
|
||||
cont.innerHTML = '';
|
||||
const list = r.conversations || [];
|
||||
if(!list.length){ cont.innerHTML = '<span class="muted">(aucune)</span>'; return; }
|
||||
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
|
||||
cont.textContent = '';
|
||||
const list = q
|
||||
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
|
||||
: CONVS;
|
||||
if(!list.length){
|
||||
const e = document.createElement('div');
|
||||
e.className = 'conv-empty';
|
||||
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
|
||||
cont.appendChild(e);
|
||||
syncTopbarTitle();
|
||||
return;
|
||||
}
|
||||
for(const c of list){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === r.active ? ' active' : '');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
row.onclick = () => convSwitch(c.id);
|
||||
|
||||
const info = document.createElement('div'); info.className = 'preset-info';
|
||||
const nm = document.createElement('div'); nm.className = 'preset-name';
|
||||
if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); }
|
||||
const title = c.title || 'Nouvelle discussion';
|
||||
nm.appendChild(document.createTextNode(title));
|
||||
nm.title = title;
|
||||
const meta = document.createElement('div'); meta.className = 'preset-meta';
|
||||
const when = document.createElement('span'); when.className = 'muted';
|
||||
when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
|
||||
meta.appendChild(when);
|
||||
info.appendChild(nm); info.appendChild(meta);
|
||||
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
|
||||
info.appendChild(nm);
|
||||
|
||||
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
|
||||
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
|
||||
// donc jamais et rien ne se chevauche.
|
||||
const when = document.createElement('span');
|
||||
when.className = 'conv-when';
|
||||
when.textContent = convWhen(c.updated);
|
||||
|
||||
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
|
||||
const acts = document.createElement('div'); acts.className = 'conv-acts';
|
||||
const ren = document.createElement('button'); ren.className = 'iconbtn'; ren.textContent = '✎';
|
||||
ren.title = 'renommer'; ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
|
||||
const del = document.createElement('button'); del.className = 'iconbtn'; del.textContent = '🗑';
|
||||
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
|
||||
const ren = document.createElement('button'); ren.className = 'iconbtn';
|
||||
ren.appendChild(icon('pencil', 15)); ren.title = 'renommer';
|
||||
ren.setAttribute('aria-label', 'renommer la discussion');
|
||||
ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
|
||||
const del = document.createElement('button'); del.className = 'iconbtn';
|
||||
del.appendChild(icon('trash', 15)); del.title = 'supprimer';
|
||||
del.setAttribute('aria-label', 'supprimer la discussion');
|
||||
del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
|
||||
acts.appendChild(ren); acts.appendChild(del);
|
||||
|
||||
row.appendChild(info); row.appendChild(acts);
|
||||
row.append(info, when, acts);
|
||||
cont.appendChild(row);
|
||||
}
|
||||
syncTopbarTitle();
|
||||
}
|
||||
|
||||
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
|
||||
// quand la barre latérale est escamotée — c'est-à-dire quand on en a besoin.
|
||||
function syncTopbarTitle(){
|
||||
const el = document.getElementById('topbar-title');
|
||||
if(!el) return;
|
||||
const cur = CONVS.find(c => c.id === CONV_ACTIVE);
|
||||
const t = (cur && cur.title) || 'Nouvelle discussion';
|
||||
el.textContent = t;
|
||||
el.title = t;
|
||||
}
|
||||
|
||||
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
|
||||
@@ -62,6 +102,8 @@ async function convNew(){
|
||||
try{
|
||||
await jpost('/api/conversations/new', {});
|
||||
toast('nouvelle discussion');
|
||||
const s = document.getElementById('conv-search');
|
||||
if(s) s.value = ''; // sinon la discussion toute neuve est masquée par le filtre
|
||||
loadConversations();
|
||||
}catch(_){ toast('erreur réseau'); }
|
||||
}
|
||||
@@ -85,7 +127,9 @@ async function convRename(id, current){
|
||||
}
|
||||
|
||||
async function convDelete(id, title){
|
||||
if(!await askConfirm('Supprimer « ' + title + ' » ? Cette discussion et son historique seront perdus.',
|
||||
// Le geste emporte aussi les fichiers de la discussion : ils lui appartiennent
|
||||
// (un dossier par discussion côté serveur), autant le dire avant.
|
||||
if(!await askConfirm('Supprimer « ' + title + ' » ? Son historique ET ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront perdus.',
|
||||
{title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
|
||||
try{
|
||||
const r = await jpost('/api/conversations/delete', {id});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// ─── Coque de l'interface ────────────────────────────────────────────────────
|
||||
// Les commandes de la refonte : escamoter la barre latérale, replier le
|
||||
// moniteur machine, basculer le thème depuis l'en-tête, changer de modèle sans
|
||||
// ouvrir les réglages.
|
||||
//
|
||||
// Chaque état vit sur <html> (data-side, data-perf, data-theme) et dans
|
||||
// localStorage, et il est REPOSÉ AVANT LE RENDU par le script du <head> : sans
|
||||
// ça, la barre latérale s'afficherait puis se refermerait sous les yeux à chaque
|
||||
// chargement de page.
|
||||
|
||||
// Escamotage de la barre latérale (grand écran). Sur téléphone elle est déjà un
|
||||
// tiroir — c'est toggleSide() qui s'en charge, et le bouton ☰ qui l'ouvre.
|
||||
function toggleSideCollapse(){
|
||||
if(window.innerWidth <= 720){ toggleSide(); return; }
|
||||
const on = document.documentElement.getAttribute('data-side') !== '0';
|
||||
document.documentElement.setAttribute('data-side', on ? '0' : '1');
|
||||
try{ localStorage.setItem('loki-side', on ? '0' : '1'); }catch(_){}
|
||||
// La largeur du fil change sans qu'aucun `resize` ne soit émis : la gouttière
|
||||
// de barre de défilement mesurée dans --sbw resterait celle d'avant, et la
|
||||
// carte de saisie serait décalée du fil.
|
||||
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
|
||||
}
|
||||
|
||||
// Bascule clair/sombre depuis l'en-tête. Elle passe par applyTheme + savePrefs
|
||||
// (01-theme.js) pour rester alignée avec l'interrupteur des réglages et avec le
|
||||
// serveur — deux chemins vers le même réglage, une seule source de vérité.
|
||||
function toggleThemeQuick(){
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
applyTheme(dark ? 'light' : 'dark');
|
||||
if(typeof savePrefs === 'function') savePrefs();
|
||||
}
|
||||
|
||||
// Déplie un <details> ET tous ceux qui le contiennent. Depuis que les réglages
|
||||
// vivent sous un repli unique, ouvrir une section par programme (journal du
|
||||
// moteur, installation llama.cpp) ne suffit plus : elle s'ouvrait à l'intérieur
|
||||
// d'un parent fermé, donc invisible.
|
||||
function openDetails(el){
|
||||
if(typeof el === 'string') el = document.getElementById(el);
|
||||
while(el){
|
||||
if(el.tagName === 'DETAILS') el.open = true;
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
|
||||
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||
// confirmation puis relance le moteur).
|
||||
function onModelSwitch(sel){
|
||||
const n = parseInt(sel.value, 10);
|
||||
if(!n) return;
|
||||
const name = sel.options[sel.selectedIndex].textContent;
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
||||
//
|
||||
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
||||
// empreinte correspond exactement à la configuration courante. Une configuration
|
||||
// écrite à la main, héritée d'une image, ou modifiée d'un cran depuis le dernier
|
||||
// preset ne correspond à AUCUN d'entre eux — et l'en-tête n'affichait alors rien
|
||||
// alors qu'un modèle était bel et bien chargé. On retombe donc sur le nom du
|
||||
// fichier de la configuration active.
|
||||
//
|
||||
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
||||
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
||||
// en parallèle (loadAll), aucun ordre n'est garanti.
|
||||
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
|
||||
|
||||
function renderModelSwitch(presets, active){
|
||||
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
||||
paintModelSwitch();
|
||||
}
|
||||
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
||||
function setLoadedModel(name){
|
||||
MS_MODEL = (name || '').trim();
|
||||
paintModelSwitch();
|
||||
}
|
||||
// Nom lisible d'un fichier de modèle : sans dossier ni extension. Un .gguf
|
||||
// s'appelle « Qwen3-14B-Instruct-Q4_K_M.gguf » — le chemin et le suffixe
|
||||
// n'apprennent rien et mangent toute la place.
|
||||
function modelLabel(path){
|
||||
const base = path.split(/[\\/]/).pop();
|
||||
return base.replace(/\.gguf$/i, '');
|
||||
}
|
||||
function paintModelSwitch(){
|
||||
const sel = document.getElementById('model-switch');
|
||||
if(!sel) return;
|
||||
const prev = sel.value;
|
||||
sel.textContent = '';
|
||||
// Ligne de tête : ce qui est RÉELLEMENT chargé, quand aucun preset ne le
|
||||
// revendique. Value vide → onModelSwitch l'ignore, elle ne bascule rien.
|
||||
if(!MS_ACTIVE){
|
||||
const o = document.createElement('option');
|
||||
o.value = '';
|
||||
o.textContent = MS_MODEL ? modelLabel(MS_MODEL) : (MS_PRESETS.length ? 'aucun modèle actif' : 'aucun preset');
|
||||
o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
MS_PRESETS.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(i + 1);
|
||||
o.textContent = p.name;
|
||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.disabled = !MS_PRESETS.length;
|
||||
sel.title = MS_MODEL
|
||||
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
||||
: 'aucun modèle configuré';
|
||||
// Une bascule en cours ne doit pas voir sa sélection sauter sous la souris.
|
||||
if(prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
|
||||
}
|
||||
+510
-58
@@ -1,42 +1,83 @@
|
||||
/* ===== Polices =====================================================
|
||||
Bricolage Grotesque (titres et texte) + IBM Plex Mono (chiffres, chemins,
|
||||
code). Servies par Loki (/fonts/, embarquées dans le binaire) et NON par
|
||||
Google Fonts : une instance locale ou coupée d'internet doit s'afficher
|
||||
correctement, sans qu'un tiers apprenne qui consulte l'interface.
|
||||
Inter (interface) + JetBrains Mono (chiffres, chemins, code). Servies par Loki
|
||||
(/fonts/, embarquées dans le binaire) et NON par Google Fonts : une instance
|
||||
locale ou coupée d'internet doit s'afficher correctement, sans qu'un tiers
|
||||
apprenne qui consulte l'interface.
|
||||
Sous-ensemble LATIN uniquement (~48 + ~31 Ko) : il couvre le français, y
|
||||
compris œ et les guillemets. Les symboles de l'UI (↻ ⌫ ☰ 🗑) n'ont jamais été
|
||||
dessinés par la police d'interface — ils viennent du système, ici comme avant.
|
||||
swap : le texte s'affiche tout de suite dans la police de repli, puis bascule
|
||||
— jamais de blanc au chargement. */
|
||||
@font-face{font-family:'Bricolage Grotesque';src:url('/fonts/bricolage.woff2') format('woff2');font-weight:400 700;font-display:swap;font-style:normal}
|
||||
@font-face{font-family:'IBM Plex Mono';src:url('/fonts/plexmono-400.woff2') format('woff2');font-weight:400;font-display:swap;font-style:normal}
|
||||
@font-face{font-family:'IBM Plex Mono';src:url('/fonts/plexmono-500.woff2') format('woff2');font-weight:500;font-display:swap;font-style:normal}
|
||||
@font-face{font-family:'Inter';src:url('/fonts/inter.woff2') format('woff2');font-weight:100 900;font-display:swap;font-style:normal}
|
||||
@font-face{font-family:'JetBrains Mono';src:url('/fonts/jetbrainsmono.woff2') format('woff2');font-weight:100 800;font-display:swap;font-style:normal}
|
||||
|
||||
/* ===== Palette « Terre » ===========================================
|
||||
Bruns chauds, vert sauge et argile. Deux variantes — claire par défaut,
|
||||
sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI passent
|
||||
par ces variables ; rien d'autre ne code une couleur en dur.
|
||||
/* ===== Palette « Sober Tech » ======================================
|
||||
Ardoise et sauge. Deux variantes — claire par défaut, sombre via
|
||||
data-theme="dark" sur <html>. Toutes les couleurs de l'UI passent par ces
|
||||
variables ; rien d'autre ne code une couleur en dur.
|
||||
|
||||
Rôles : --accent = sauge (sélection, marche, liens) · --warn = argile
|
||||
(chiffres, mesures, avertissements) · --err = terre cuite (arrêt, danger).
|
||||
--row-bg / --code-bg sont les deux fonds encastrés d'un panneau. */
|
||||
⚠️ Les valeurs NOMMÉES viennent des deux planches officielles du projet Stitch
|
||||
« Loki AI Chat Modern Redesign v1 » — « Official Palette for Loki AI v1 » et
|
||||
« Official Dark Mode Palette for Loki AI » — pas d'une approximation à l'œil :
|
||||
|
||||
clair Main BG #F5F6F8 · Sidebar BG #ECECEC · Primary Accent #95A899
|
||||
Typography #333333
|
||||
sombre Deep Navy #0F172A · Slate Surface #1E293B · Sage Accent #84A98C
|
||||
|
||||
Le reste (carte de réponse, carte de saisie, piste de jauge, bulle sombre) est
|
||||
relevé sur les écrans de la maquette, qui posent une hiérarchie de surfaces
|
||||
que l'intuition inverse volontiers :
|
||||
|
||||
barre latérale la plus SOMBRE des trois #ECECEC / #0F172A
|
||||
fil la surface de référence #F5F6F8 / #1E293B
|
||||
carte s'ÉCARTE du fil #EAEBEE / #2A3646
|
||||
saisie la plus claire #FBFBFD / #1B2436
|
||||
|
||||
En clair la carte de réponse est donc plus sombre que le fil (et non blanche),
|
||||
en sombre plus claire : dans les deux cas elle se détache en s'éloignant du
|
||||
fond, jamais en allant vers le blanc.
|
||||
|
||||
La bulle de l'utilisateur ne suit PAS l'accent : sauge en clair (#7EA58A),
|
||||
ardoise en sombre (#334257) — la maquette réserve le vert aux jauges dès que
|
||||
le fond est sombre.
|
||||
|
||||
Rôles : --accent = sauge (sélection, marche, liens, bulle utilisateur) ·
|
||||
--warn = ambre (chiffres, mesures, avertissements) · --err = rouille (arrêt,
|
||||
danger). --row-bg / --code-bg sont les deux fonds encastrés d'un panneau.
|
||||
--code-bg est nettement plus soutenu que --bubble : un bloc de code posé DANS
|
||||
la carte de réponse doit se lire comme un encadré, pas se fondre dedans.
|
||||
|
||||
--on-accent : ce qui s'écrit SUR un aplat de sauge. Il change avec la
|
||||
variante — blanc sur la sauge foncée du thème clair, ardoise sur la sauge
|
||||
claire du thème sombre — sinon un des deux thèmes écrit du texte à 2:1 de
|
||||
contraste sur la bulle utilisateur et le bouton d'envoi. */
|
||||
:root{color-scheme:light;
|
||||
--font:'Bricolage Grotesque',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
|
||||
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
--bg:#f3efe8;--panel:#ffffff;--bubble:#efe9df;--border:rgba(44,38,34,.12);
|
||||
--text:#2c2622;--dim:#7a7069;
|
||||
--accent:#5f7f52;--accent-bg:rgba(95,127,82,.12);
|
||||
--row-bg:#f7f4ee;--row-bg-on:#f4f1ea;
|
||||
--ok:#5f7f52;--warn:#a9662f;--err:#c05a3f;--mag:#a9662f;
|
||||
--code-bg:#f7f4ee;--danger-bg:#f8e9e4;--warn-bg:#f6efe4;
|
||||
--add:#5f7f52;--add-bg:rgba(95,127,82,.12);--del:#c05a3f;--del-bg:rgba(192,90,63,.12);
|
||||
--shadow:0 1px 2px rgba(44,38,34,.06)}
|
||||
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
|
||||
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
--bg:#f5f6f8;--panel:#fbfbfd;--bubble:#eaebee;--border:rgba(51,51,51,.11);
|
||||
--text:#333333;--dim:#6b7280;
|
||||
--accent:#587062;--accent-bg:rgba(149,168,153,.20);--on-accent:#ffffff;--on-err:#ffffff;
|
||||
--accent-fill:#95a899;--accent-soft:#95a899;
|
||||
--user-bg:#95a899;--on-user:#ffffff;
|
||||
--side:#ececec;--row-bg:#f0f0f1;--row-bg-on:#e0e0e2;--track:#dadbdf;
|
||||
--ok:#587062;--warn:#a86a2a;--err:#c0503f;--mag:#a86a2a;
|
||||
--code-bg:#e2e3e7;--danger-bg:#f5e3e0;--warn-bg:#f2ece1;
|
||||
--add:#587062;--add-bg:rgba(149,168,153,.20);--del:#c0503f;--del-bg:rgba(192,80,63,.12);
|
||||
--shadow:0 1px 2px rgba(15,23,42,.05);--shadow-lift:0 4px 16px rgba(15,23,42,.08);
|
||||
--shadow-card:0 2px 10px rgba(15,23,42,.06),0 1px 3px rgba(15,23,42,.04)}
|
||||
/* « Deep Dark » : fond #0F172A, cartes #1E293B. */
|
||||
[data-theme="dark"]{color-scheme:dark;
|
||||
--bg:#211d1a;--panel:#2b2622;--bubble:#38312a;--border:rgba(236,229,221,.12);
|
||||
--text:#ece5dd;--dim:#a99d92;
|
||||
--accent:#9ab68c;--accent-bg:rgba(154,182,140,.15);
|
||||
--row-bg:#332d28;--row-bg-on:#38312a;
|
||||
--ok:#9ab68c;--warn:#d2a07a;--err:#c9705c;--mag:#d2a07a;
|
||||
--code-bg:#211d1a;--danger-bg:#3a2621;--warn-bg:#332b23;
|
||||
--add:#9ab68c;--add-bg:rgba(154,182,140,.15);--del:#c9705c;--del-bg:rgba(201,112,92,.15);
|
||||
--shadow:none}
|
||||
--bg:#1e293b;--panel:#1b2436;--bubble:#2a3646;--border:rgba(148,163,184,.16);
|
||||
--text:#e2e8f0;--dim:#94a3b8;
|
||||
--accent:#a3c4ab;--accent-bg:rgba(132,169,140,.22);--on-accent:#0f172a;--on-err:#0f172a;
|
||||
--accent-fill:#84a98c;--accent-soft:#84a98c;
|
||||
--user-bg:#334259;--on-user:#e8eef5;
|
||||
--side:#0f172a;--row-bg:#1a2334;--row-bg-on:#232c3f;--track:#232a3a;
|
||||
--ok:#a3c4ab;--warn:#d9a55f;--err:#e0705c;--mag:#d9a55f;
|
||||
--code-bg:#16202f;--danger-bg:#3a1f1c;--warn-bg:#33291b;
|
||||
--add:#a3c4ab;--add-bg:rgba(132,169,140,.22);--del:#e0705c;--del-bg:rgba(224,112,92,.16);
|
||||
--shadow:none;--shadow-lift:0 4px 16px rgba(0,0,0,.35);
|
||||
--shadow-card:0 2px 12px rgba(0,0,0,.28),0 1px 3px rgba(0,0,0,.20)}
|
||||
/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien,
|
||||
résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors
|
||||
de notre charte et impossible à styler : on le coupe. Nos états :active/:focus
|
||||
@@ -119,7 +160,6 @@ option,optgroup{background:var(--panel);color:var(--text)}
|
||||
.msg{max-width:92%}
|
||||
#chat .msg.assistant,#chat .msg.reasoning{max-width:100%}
|
||||
#inputbar{padding:8px;gap:4px}
|
||||
#send,#stop{padding:0 12px}
|
||||
/* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et
|
||||
ça prend de la place. Elle reste visible sur desktop. */
|
||||
#sendhint{display:none}
|
||||
@@ -465,7 +505,6 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
@media (max-width:720px){
|
||||
.side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
|
||||
}
|
||||
[data-hide-side="1"] .side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
|
||||
.sidehead{margin-bottom:10px}
|
||||
|
||||
/* --- En-tête de section --- */
|
||||
@@ -601,24 +640,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
data-hide-reasoning : masque les bulles de raisonnement
|
||||
data-hide-tools : masque les bulles d'appel d'outil
|
||||
data-fold-tools : les garde repliées (dépliables au clic)
|
||||
data-hide-side : barre latérale en tiroir, même sur ordinateur
|
||||
Quand raisonnement et outils sont masqués, l'activité reste signalée par
|
||||
l'indicateur de frappe (.msg.typing) maintenu pendant tout le tour. */
|
||||
[data-hide-reasoning="1"] #chat .msg.reasoning{display:none}
|
||||
[data-hide-tools="1"] #chat .msg.tool{display:none}
|
||||
/* Barre latérale en tiroir sur TOUTES les tailles d'écran : mêmes règles que
|
||||
la version mobile, activées par l'attribut au lieu de la largeur. */
|
||||
[data-hide-side="1"] .side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left));border-right:1px solid var(--border)}
|
||||
@media (min-width:721px){[data-hide-side="1"] .side{width:380px;max-width:380px}}
|
||||
/* Le bouton ☰ est posé au-dessus du fil : on dégage le haut de la
|
||||
conversation pour qu'il ne recouvre aucun message. */
|
||||
@media (min-width:721px){html[data-hide-side="1"] #chat{padding-top:60px}}
|
||||
[data-hide-side="1"] .side.open{transform:translateX(0)}
|
||||
[data-hide-side="1"] #menubtn{display:block}
|
||||
[data-hide-side="1"] #lc-chip:not([hidden]){display:block}
|
||||
[data-hide-side="1"] #backdrop.open{display:block}
|
||||
[data-hide-side="1"] body.drawer-open #menubtn,[data-hide-side="1"] body.drawer-open #lc-chip{display:none}
|
||||
[data-hide-side="1"] .main{width:100%}
|
||||
/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui
|
||||
peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli.
|
||||
width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0
|
||||
@@ -647,7 +672,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
#sendhint.waiting{opacity:1;color:var(--dim)}
|
||||
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
|
||||
#input:focus{outline:none;border-color:var(--accent)}
|
||||
#send{padding:0 20px}
|
||||
.muted{color:var(--dim);font-size:13px}
|
||||
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||||
#toast.show{opacity:1}
|
||||
@@ -679,7 +703,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
/* PAS de font-size < 16px ici : iOS Safari zoome à la sélection du champ. On
|
||||
garde 16px (hérité de la règle anti-zoom) et on compacte via hauteur/padding. */
|
||||
#input{min-height:34px;max-height:120px;padding:6px 9px}
|
||||
#send,#stop{padding:0 14px}
|
||||
#composer .muted{display:none}
|
||||
#ctxmeta{padding:3px 10px 0}
|
||||
h1{font-size:15px}
|
||||
@@ -974,12 +997,27 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px}
|
||||
.mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
/* Version du moteur : même pastille, mais SANS majuscules forcées — un numéro
|
||||
de build et un hash de commit se recopient et se comparent à l'œil, et
|
||||
« AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */
|
||||
.eng-ver{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--text)}
|
||||
.mcp-tools{font-size:10px;color:var(--accent)}
|
||||
.mcp-err{font-size:10px;color:var(--err)}
|
||||
.mcp-switch{flex:none}
|
||||
.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5}
|
||||
.mcp-edit:hover{opacity:1;color:var(--accent)}
|
||||
/* Liste des outils MCP dans la modale */
|
||||
.mcp-actions{display:flex;gap:6px;margin-top:6px}
|
||||
.mcp-cat-tabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}
|
||||
.mcp-cat{font-size:11px;padding:2px 9px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer}
|
||||
.mcp-cat:hover{border-color:var(--accent);color:var(--text)}
|
||||
.mcp-cat.on{border-color:var(--accent);color:var(--accent)}
|
||||
.mcp-cat-list{display:flex;flex-direction:column;gap:4px;max-height:52vh;overflow:auto}
|
||||
.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel)}
|
||||
.mcp-cat-row:hover{border-color:var(--accent)}
|
||||
.mcp-cat-desc{font-size:11px;color:var(--dim);margin-top:2px;line-height:1.35}
|
||||
.mcp-cat-cmd{font-size:10px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:340px}
|
||||
.mcp-cat-use{flex:none}
|
||||
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px}
|
||||
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer}
|
||||
.mcp-tool-row:hover{background:var(--panel)}
|
||||
@@ -1077,9 +1115,6 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
|
||||
bordure, seule la saisie change (curseur). */
|
||||
#input:focus{border-color:transparent}
|
||||
#send,#stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0}
|
||||
#send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600}
|
||||
#send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)}
|
||||
/* Pied de carte : c'est LUI qui porte l'arrondi bas (26 px, comme en haut) et
|
||||
qui clippe la jauge. Un bandeau de 7 px ne peut pas dessiner un rayon de 26 px
|
||||
(le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte
|
||||
@@ -1290,10 +1325,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
/* Action principale en sauge : c'est la couleur d'action de la charte, et elle
|
||||
se distingue enfin du texte (le bouton reprenait --text, donc un brun sombre
|
||||
indistinct du reste). L'arrêt, lui, reste en terre cuite. */
|
||||
#send{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
#send:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:#fff;opacity:.88}
|
||||
#stop{background:var(--err);border-color:var(--err);color:#fff}
|
||||
#stop:hover{background:var(--err);border-color:var(--err);color:#fff;opacity:.88}
|
||||
|
||||
/* --- Terre : largeur de lecture --------------------------------------
|
||||
La mesure était plafonnée à 760 px et centrée sur la fenêtre : sur un écran
|
||||
@@ -1409,7 +1440,14 @@ html[data-files="1"] #files{display:flex}
|
||||
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
|
||||
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
|
||||
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border)}
|
||||
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border);
|
||||
display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
/* Détour « hors discussion » : discret, en pied de panneau — c'est une sortie de
|
||||
secours pour les fichiers d'avant le rangement par discussion, pas une vue
|
||||
qu'on propose au même rang que la discussion ouverte. */
|
||||
.files-scope{margin:0;padding:2px 6px;min-height:0;font-family:var(--mono);font-size:10px;
|
||||
background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--dim)}
|
||||
.files-scope:hover{color:var(--text);background:var(--row-bg-on)}
|
||||
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
|
||||
.files-row.busy{opacity:.55;pointer-events:none}
|
||||
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand
|
||||
@@ -1434,3 +1472,417 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
|
||||
box-shadow:var(--shadow);margin:0}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
===== Refonte « Sober Tech » ========================================
|
||||
=====================================================================
|
||||
Dernière section du fichier, donc dernier mot de la cascade : elle porte la
|
||||
coque de la refonte (barre d'en-tête, zones de la barre latérale, moniteur
|
||||
machine) et redresse les quelques règles historiques qu'elle contredit.
|
||||
|
||||
Trois principes, tirés de la maquette :
|
||||
1. Le fil est la seule chose qui compte : tout le reste est atténué, sans
|
||||
cadre superflu, et la barre latérale s'escamote d'un clic.
|
||||
2. Les données machine sont TOUJOURS visibles mais jamais au premier plan —
|
||||
un pied de barre latérale, trois jauges, pas un tableau.
|
||||
3. Aucun contour permanent sur les boutons : l'affordance vient du survol
|
||||
(fond qui apparaît, léger enfoncement au clic).
|
||||
===================================================================== */
|
||||
|
||||
/* --- Coque ---------------------------------------------------------- */
|
||||
.side{background:var(--side);display:flex;flex-direction:column;gap:0;padding:0;overflow:hidden}
|
||||
/* Les trois zones de la barre : en-tête fixe, contenu qui défile, moniteur
|
||||
posé en pied. Sans ça, la liste des discussions poussait les jauges hors de
|
||||
l'écran dès la dixième discussion. */
|
||||
.sidehead{flex:none;display:flex;align-items:center;gap:8px;margin:0;
|
||||
padding:14px 14px 10px;padding-top:calc(14px + env(safe-area-inset-top))}
|
||||
#side-scroll{flex:1;min-height:0;overflow-y:auto;padding:0 0 12px;
|
||||
scrollbar-gutter:stable;display:flex;flex-direction:column;gap:0}
|
||||
#side-perf{flex:none;border-top:1px solid var(--border);padding:12px 14px;
|
||||
padding-bottom:calc(12px + env(safe-area-inset-bottom))}
|
||||
@media (max-width:720px){
|
||||
.side{padding:0}
|
||||
#side-scroll{padding-left:calc(12px + env(safe-area-inset-left))}
|
||||
.sidehead{padding-left:calc(14px + env(safe-area-inset-left))}
|
||||
}
|
||||
/* Escamotage sur grand écran : la barre disparaît, le fil reprend la place.
|
||||
L'état est persisté (localStorage) et posé sur <html> avant le rendu, comme
|
||||
le thème — sinon la barre apparaît puis se referme sous les yeux. */
|
||||
@media (min-width:721px){
|
||||
html[data-side="0"] .side{display:none}
|
||||
html[data-side="0"] #sidetoggle svg{transform:none}
|
||||
}
|
||||
#sidetoggle svg{transform:scaleX(-1)}
|
||||
|
||||
/* --- Barre d'en-tête du fil ----------------------------------------- */
|
||||
/* Elle porte ce qui décrit la conversation en cours : de quoi on parle, et avec
|
||||
QUEL modèle. Le choix du modèle vivait au fond de la barre latérale, dans une
|
||||
liste de presets — trois clics et un défilement pour une action qu'on fait
|
||||
plusieurs fois par session. */
|
||||
#topbar{flex:none;display:flex;align-items:center;gap:10px;padding:10px 16px;
|
||||
padding-top:calc(10px + env(safe-area-inset-top));
|
||||
border-bottom:1px solid var(--border);background:var(--bg);z-index:5}
|
||||
#topbar-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
font-size:13.5px;font-weight:500;color:var(--text)}
|
||||
#topbar .iconbtn{width:30px;height:30px;flex:none;display:inline-flex;align-items:center;
|
||||
justify-content:center;border:0;border-radius:9px;background:transparent;color:var(--dim);
|
||||
cursor:pointer;margin:0;padding:0;transition:background .15s,color .15s,transform .1s}
|
||||
#topbar .iconbtn:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
#topbar .iconbtn:active{transform:scale(.92)}
|
||||
#topbar .iconbtn svg{display:block}
|
||||
/* Sélecteur de modèle : le libellé n'est PAS une étiquette flottante mais le nom
|
||||
du preset actif, lisible d'un coup d'œil. */
|
||||
#model-switch{max-width:230px;font:inherit;font-size:12.5px;color:var(--text);
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:999px;
|
||||
padding:6px 30px 6px 12px;cursor:pointer;appearance:none;
|
||||
background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),linear-gradient(135deg,var(--dim) 50%,transparent 50%);
|
||||
background-position:calc(100% - 15px) 52%,calc(100% - 11px) 52%;
|
||||
background-size:4px 4px,4px 4px;background-repeat:no-repeat}
|
||||
#model-switch:hover{border-color:var(--accent)}
|
||||
#model-switch:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
@media (max-width:720px){
|
||||
#topbar{padding-left:56px} /* la place du bouton ☰, en position fixe */
|
||||
#model-switch{max-width:130px}
|
||||
}
|
||||
|
||||
/* --- Discussions ----------------------------------------------------- */
|
||||
/* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne.
|
||||
Le reste (réglages) descend sous un unique repli. */
|
||||
#conv-search{width:calc(100% - 24px);font:inherit;font-size:12.5px;color:var(--text);
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:9px;
|
||||
padding:7px 11px;margin:0 12px 8px}
|
||||
#conv-search::placeholder{color:var(--dim)}
|
||||
#conv-search:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
.side .preset{padding:11px 14px;margin:0;gap:10px;border-radius:0;
|
||||
border:0;border-bottom:1px solid var(--border)}
|
||||
.side .preset .preset-name{font-size:13px;font-weight:450;white-space:nowrap;overflow:hidden;
|
||||
text-overflow:ellipsis;word-break:normal;overflow-wrap:normal}
|
||||
.side .preset.active .preset-name{font-weight:550}
|
||||
/* Heure de la dernière activité : à DROITE du titre, sur la même ligne, en
|
||||
monospace pour que les colonnes s'alignent d'une ligne à l'autre.
|
||||
⚠️ Sélecteur en TROIS classes : `.preset>span` (plus haut) pose flex:1 sur
|
||||
tout span enfant d'une ligne — l'heure prenait donc la MOITIÉ de la ligne et
|
||||
le titre était tronqué au premier mot. */
|
||||
.side .preset .conv-when{flex:none;font-family:var(--mono);font-size:10.5px;
|
||||
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}
|
||||
/* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette
|
||||
seule ligne, pour que la barre latérale de la maquette (discussions +
|
||||
machine) reste la vue par défaut. */
|
||||
#side-settings{margin-top:8px;border-top:1px solid var(--border)}
|
||||
#side-settings>summary{height:38px}
|
||||
#side-settings>.sidegroup{margin-top:2px}
|
||||
|
||||
/* --- Moniteur machine (pied de barre latérale) ----------------------- */
|
||||
.perf-head{display:flex;align-items:center;gap:8px;margin-bottom:11px;
|
||||
font-size:14px;font-weight:600;color:var(--text);letter-spacing:-.01em}
|
||||
.perf-row{margin:0 0 14px}
|
||||
.perf-l{display:flex;align-items:baseline;gap:8px;font-size:12.5px;color:var(--text);margin-bottom:6px}
|
||||
.perf-l b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
font-weight:400;color:var(--text)}
|
||||
.perf-l .perf-pct{font-size:12.5px;color:var(--text);font-variant-numeric:tabular-nums}
|
||||
/* La jauge : 4 px, coins ronds, remplissage sauge. Elle passe à l'ambre puis au
|
||||
rouille quand la machine sature — c'est là que l'information devient utile. */
|
||||
.perf-track{height:7px;border-radius:999px;background:var(--track);overflow:hidden}
|
||||
.perf-fill{height:100%;width:0;border-radius:999px;background:var(--accent-fill);
|
||||
transition:width .4s ease,background .4s ease}
|
||||
.perf-fill.hot{background:var(--warn)}
|
||||
.perf-fill.max{background:var(--err)}
|
||||
|
||||
/* --- Boutons : un cadre, et une micro-interaction au survol -----------
|
||||
La refonte les avait voulus « sans contour » : contour transparent, et un
|
||||
aplat pris sur --row-bg. Or c'est exactement le fond des blocs qui les
|
||||
contiennent (.slist, .rowbody) : un bouton posé dessus n'avait plus ni trait
|
||||
ni écart de valeur, et « démarrer / redémarrer / arrêter » ou « mettre à jour
|
||||
le moteur » se lisaient comme du texte, pas comme des commandes.
|
||||
On revient donc au cadre — celui d'avant la refonte : trait de 1 px et aplat
|
||||
un ton AU-DESSUS du bloc, pour que le bouton se détache dans les deux cas.
|
||||
La micro-interaction, elle, reste : elle est bonne, elle manquait juste de
|
||||
quelque chose à animer.
|
||||
⚠️ Ne concerne pas les boutons-icônes de la saisie et de l'en-tête (#send,
|
||||
#stop, #attach, #menubtn…) : ils posent `border:0` PLUS BAS dans la feuille
|
||||
et gardent leur allure nue, qui est la bonne pour un pictogramme seul. */
|
||||
button{background:var(--panel);border-color:var(--border);border-radius:9px;
|
||||
transition:background .15s ease,color .15s ease,border-color .15s ease,transform .08s ease}
|
||||
button:hover{background:var(--row-bg);border-color:var(--dim);color:var(--text)}
|
||||
button:active:not(:disabled){transform:translateY(1px)}
|
||||
.side .preset,.slist,#conv-search{transition:background .15s ease,border-color .15s ease}
|
||||
|
||||
/* --- Fil : la réponse redevient une carte --------------------------- */
|
||||
/* La maquette encadre la réponse de l'IA : sur un fond de fil coloré, du texte
|
||||
nu flotte sans ancrage, et la frontière entre deux réponses se perd. */
|
||||
#chat{padding-top:22px}
|
||||
#chat .msg.assistant{background:var(--bubble);border:0;
|
||||
border-radius:10px;padding:15px 17px;max-width:100%;box-shadow:var(--shadow-card)}
|
||||
#chat .msg.assistant.errmsg{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 45%,transparent)}
|
||||
/* Question : aplat de sauge, coin bas-droit rentré. --on-accent garantit le
|
||||
contraste dans les DEUX variantes. */
|
||||
#chat .msg.user{background:var(--user-bg);color:var(--on-user);border-radius:10px;
|
||||
padding:13px 16px;max-width:62%;box-shadow:var(--shadow-card)}
|
||||
#chat .msg.user .label{color:var(--on-user);opacity:.75}
|
||||
#chat .msg.user .body a{color:var(--on-user);text-decoration:underline}
|
||||
#chat .msg.user code{background:rgba(255,255,255,.18);color:inherit}
|
||||
[data-theme="dark"] #chat .msg.user code{background:rgba(15,23,42,.22)}
|
||||
/* Les pastilles de fichier d'une question sont posées SUR l'aplat de sauge :
|
||||
sans fond propre, elles s'y fondaient. */
|
||||
#chat .msg.user .msg-files .chip-file{background:var(--panel);color:var(--text);border-color:transparent}
|
||||
|
||||
/* --- Saisie : la pilule de la maquette ------------------------------ */
|
||||
/* Le bouton d'envoi devient une pastille ronde à flèche : c'est la forme de la
|
||||
maquette, et elle laisse toute la largeur au texte. Le mot « Envoyer » reste
|
||||
porté par aria-label et par l'infobulle — l'action n'est pas moins nommée
|
||||
pour un lecteur d'écran, elle l'est moins à l'écran. */
|
||||
/* Les QUATRE boutons de la barre de saisie — joindre, fichiers, envoyer,
|
||||
arrêter — sont un seul et même objet : 34x34, coins doux (jamais un rond :
|
||||
la maquette n'en a pas), icône de 18 px centrée, fond seulement au survol.
|
||||
Seule la couleur les distingue : atténué pour les secondaires, sauge pour
|
||||
l'envoi, rouille pour l'arrêt.
|
||||
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
|
||||
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
|
||||
taille, sa forme et son alignement d'une couche différente. */
|
||||
#inputbar #attach,#inputbar #files-btn,#send,#stop{
|
||||
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
|
||||
display:inline-flex;align-items:center;justify-content:center;align-self:center;
|
||||
border:0;border-radius:10px;background:transparent;color:var(--dim);
|
||||
transition:background .15s ease,color .15s ease,transform .08s ease}
|
||||
/* Envoyer / arrêter : un seul des deux à la fois, décidé par data-busy (posé par
|
||||
syncSendBtn). C'est la feuille qui garde la main sur `display` — un style
|
||||
inline y substituait `inline-block`, et le centrage flex tombait avec lui. */
|
||||
#stop{display:none}
|
||||
html[data-busy="1"] #send{display:none}
|
||||
html[data-busy="1"] #stop{display:inline-flex}
|
||||
#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
||||
#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#send{color:var(--accent)}
|
||||
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
|
||||
#send:disabled{background:transparent;color:var(--dim);opacity:.45}
|
||||
#stop{color:var(--err)}
|
||||
#stop:hover{background:var(--danger-bg);color:var(--err);opacity:1}
|
||||
#inputbar #attach:active,#inputbar #files-btn:active,#send:active:not(:disabled),#stop:active{transform:scale(.92)}
|
||||
/* Les outils de la saisie (joindre, fichiers, envoyer) montent DANS la pilule,
|
||||
à droite du texte : c'est là qu'on les cherche, et le pied de carte redevient
|
||||
ce qu'il doit être — une jauge de contexte, rien d'autre. */
|
||||
#inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow-card);
|
||||
border-radius:14px 14px 0 0}
|
||||
#ctxmeta{border-radius:0 0 14px 14px}
|
||||
#ctxmeta{padding-bottom:12px}
|
||||
#ctxtrack{border-radius:999px}
|
||||
|
||||
/* --- Coque à plat ---------------------------------------------------
|
||||
La direction précédente posait deux CARTES flottantes séparées par une
|
||||
gouttière ; la maquette est bord à bord : barre latérale collée au bord,
|
||||
simple filet de séparation, fil qui occupe tout le reste. On neutralise donc
|
||||
ici les règles de coque de la section « Terre » plutôt que de les réécrire
|
||||
plus haut — la cascade fait le tri, et l'historique reste lisible. */
|
||||
@media (min-width:721px){
|
||||
body{padding:0;gap:0}
|
||||
html[data-pwa="1"] body{padding-top:env(safe-area-inset-top)}
|
||||
.side{width:300px;border-right:1px solid var(--border);border-radius:0;
|
||||
box-shadow:none;background:var(--side);overflow:hidden}
|
||||
/* Dans la barre, plus de cartes : des lignes posées sur le fond. Deux niveaux
|
||||
de cadre imbriqués (carte de section + carte de liste) pour une liste de
|
||||
titres, c'était deux de trop. */
|
||||
#side-scroll>details,#side-scroll>.sidegroup{background:transparent;box-shadow:none;
|
||||
border-radius:0;padding:0;margin:0}
|
||||
#side-scroll>details>summary{height:34px}
|
||||
.side .slist{background:transparent;border-color:transparent;border-radius:12px}
|
||||
.side #side-settings .slist{background:var(--row-bg);border-radius:12px}
|
||||
/* Le fil n'est plus une carte : il EST la page. */
|
||||
.main{background:transparent;border-radius:0;box-shadow:none;overflow:visible}
|
||||
#composer{background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))}
|
||||
#inputbar,#ctxmeta{background:var(--panel);border-color:var(--border)}
|
||||
#input{background:transparent}
|
||||
}
|
||||
/* Ligne de discussion : les actions (renommer, supprimer) sont posées EN
|
||||
SURIMPRESSION à droite plutôt qu'en flux. En flux, elles réservaient leur
|
||||
largeur en permanence — invisibles mais présentes — et le titre était tronqué
|
||||
au tiers de la place disponible. */
|
||||
.side .preset{position:relative}
|
||||
.side .preset .conv-acts{position:absolute;right:6px;top:50%;transform:translateY(-50%);
|
||||
margin:0;background:var(--row-bg-on);border-radius:9px;padding:1px}
|
||||
.side .preset.active .conv-acts{background:var(--row-bg)}
|
||||
.side .preset .preset-info{overflow:hidden}
|
||||
/* La sélection est un aplat, pas un cadre : un liseré autour d'une ligne de
|
||||
liste alourdit là où un fond suffit à dire « c'est celle-ci ». */
|
||||
.side .preset.active{background:var(--row-bg-on);border-color:transparent}
|
||||
.side .preset.active .preset-name{color:var(--text)}
|
||||
|
||||
/* --- Téléphone : un seul bouton de barre latérale -------------------- */
|
||||
/* Le ☰ flottant ouvre déjà le tiroir ; garder EN PLUS l'icône d'escamotage de
|
||||
l'en-tête donnait deux boutons voisins pour la même action, et grignotait la
|
||||
place du titre sur un écran qui n'en a pas. */
|
||||
@media (max-width:720px){
|
||||
/* Sélecteur en DEUX id : `#topbar .iconbtn` (plus haut) pose déjà un display,
|
||||
et il l'emporterait sur un simple `#sidetoggle`. */
|
||||
#topbar #sidetoggle{display:none}
|
||||
#topbar{padding-left:52px;padding-right:10px;gap:6px}
|
||||
#topbar-title{font-size:13px}
|
||||
}
|
||||
/* Le ☰ suit la charte des boutons : pas de contour, un fond qui apparaît au
|
||||
survol. Il flotte au-dessus de l'en-tête, il doit donc en reprendre le fond
|
||||
pour ne pas laisser voir le fil défiler dessous. */
|
||||
#menubtn{background:var(--bg);border:0;color:var(--dim);border-radius:10px;
|
||||
width:34px;height:34px;font-size:16px;top:calc(11px + env(safe-area-inset-top));left:9px}
|
||||
#menubtn:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
#lc-chip{border-radius:10px;height:34px;top:calc(11px + env(safe-area-inset-top));left:calc(50px + env(safe-area-inset-left))}
|
||||
/* Jauges : la piste doit se VOIR, sinon une barre presque vide ressemble à un
|
||||
trait souligné sous l'intitulé. Valeurs relevées sur la maquette. */
|
||||
.perf-track{background:var(--track)}
|
||||
/* Une liste déroulante posée directement dans une ligne de modale n'a pas de
|
||||
garde-fou de largeur (contrairement à .pe-row-c) : une valeur très longue —
|
||||
un chemin de destination, par exemple — débordait sous l'intitulé. */
|
||||
.pe-row>.pe-selc{max-width:62%}
|
||||
/* Marque et attribution du fork empilées, pastille d'état à leur droite. */
|
||||
.sidebrand{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
|
||||
.sidebrand .brand-fork{margin:0;font-size:10px;line-height:1.3}
|
||||
/* Blocs de code du fil : encadré franc dans la carte de réponse — fond plus
|
||||
soutenu, filet net et coins alignés sur ceux de la carte. Le bouton « copier »
|
||||
reprend le fond du bloc pour ne pas flotter sur le texte. */
|
||||
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{
|
||||
background:var(--code-bg);border:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
|
||||
#chat .msg.assistant .body code{background:var(--code-bg);
|
||||
border:1px solid color-mix(in srgb,var(--text) 7%,transparent);border-radius:5px}
|
||||
#chat .msg.assistant .body pre code{border:0;background:transparent}
|
||||
/* --- Titres de la barre latérale ------------------------------------
|
||||
La maquette n'a qu'un style de titre : casse normale, demi-gras, taille du
|
||||
texte courant (« Performance »). Le monospace en capitales de la charte
|
||||
précédente y jurait, et faisait crier des libellés qui n'ont rien à dire. */
|
||||
/* Le panneau Fichiers porte les mêmes titres que la barre latérale : un seul
|
||||
style de titre dans toute l'application, celui de la maquette. */
|
||||
.stitle{font-family:var(--font);text-transform:none;letter-spacing:-.01em;
|
||||
font-size:13.5px;font-weight:600;color:var(--text)}
|
||||
.side-sep{font-family:var(--font);text-transform:none;letter-spacing:0;
|
||||
font-size:12px;font-weight:600;color:var(--dim)}
|
||||
#side-scroll>details>summary,#side-settings>summary{padding:0 14px}
|
||||
#side-settings{margin-top:0;border-top:1px solid var(--border)}
|
||||
#side-settings>.sidegroup{padding:0 12px}
|
||||
/* La liste elle-même n'a pas de gouttière : ses lignes touchent les deux bords,
|
||||
comme dans la maquette. Le filet du bas de la dernière ligne fait la
|
||||
séparation avec ce qui suit. */
|
||||
#conv-list{margin:0}
|
||||
.side .preset:hover{background:var(--row-bg)}
|
||||
.side .preset .conv-acts{right:8px}
|
||||
/* Message d'absence de matériel : même famille que les jauges, en plus discret.
|
||||
(La sous-ligne chiffrée, elle, a quitté l'affichage — le détail est en
|
||||
infobulle, comme dans la maquette.) */
|
||||
.perf-sub{font-size:11.5px;color:var(--dim)}
|
||||
/* --- Carte de saisie : le trait sauge de la maquette -----------------
|
||||
C'est le seul contour coloré de l'écran, et il désigne l'endroit où l'on
|
||||
écrit. ⚠️ Posé EN FIN DE FEUILLE : la règle de coque plus haut
|
||||
(`#inputbar,#ctxmeta{border-color:var(--border)}`) reprenait sinon la main et
|
||||
le cadre retombait au gris commun.
|
||||
La flèche d'envoi prend la MÊME sauge que la maquette — c'est un aplat plein
|
||||
de 18 px, pas du texte. */
|
||||
@media (min-width:721px){
|
||||
#inputbar,#ctxmeta{border-color:var(--accent-fill)}
|
||||
#composer:focus-within #inputbar,#composer:focus-within #ctxmeta{border-color:var(--accent)}
|
||||
}
|
||||
#inputbar,#ctxmeta{border-color:var(--accent-fill)}
|
||||
#send{color:var(--accent-fill)}
|
||||
#send:hover:not(:disabled){color:var(--accent)}
|
||||
|
||||
/* --- Échelle typographique des planches ------------------------------
|
||||
« Typography: Inter » — Bold 24px pour les titres, Regular 16px pour le corps,
|
||||
Medium 14px pour le texte secondaire. On l'applique là où elle a un sens
|
||||
(le fil, la barre latérale) plutôt qu'en gonflant toute l'interface : les
|
||||
panneaux de réglages sont des formulaires denses, pas du texte de lecture. */
|
||||
#chat .msg .body{font-size:16px}
|
||||
#chat .msg.user .body,#chat .msg.assistant .body{line-height:1.6}
|
||||
#input{font-size:16px}
|
||||
.side .preset .preset-name{font-size:14px}
|
||||
.side .preset .conv-when{font-size:13px}
|
||||
.perf-head{font-size:16px;font-weight:700}
|
||||
.perf-l,.perf-l .perf-pct{font-size:14px;font-weight:500}
|
||||
.stitle{font-size:14px;font-weight:600}
|
||||
/* Marque : « Loki v0.9.4 » en tête de colonne, à l'échelle des titres. */
|
||||
#brand svg{height:20px}
|
||||
#ver{font-size:13px;font-weight:500;margin-left:8px}
|
||||
|
||||
/* --- Boutons-icônes : une seule géométrie --------------------------
|
||||
Toutes ces commandes portaient un GLYPHE DE TEXTE, donc une taille et une
|
||||
assiette décidées par la police du système. En SVG, elles se centrent
|
||||
normalement — encore faut-il que leur bouton soit une boîte flex et non une
|
||||
ligne de texte. */
|
||||
/* ⚠️ On NE touche PAS à `display` ici : plusieurs de ces boutons s'affichent et
|
||||
se masquent par ce biais (#menubtn et .preset-add sont en display:none au
|
||||
repos, #scrollbtn aussi). Une règle de centrage qui pose display:inline-flex
|
||||
les rendrait visibles en permanence — le ☰ s'est ainsi retrouvé par-dessus
|
||||
l'en-tête sur grand écran. Le centrage passe donc par le rembourrage, que la
|
||||
taille connue de chaque bouton rend exact. */
|
||||
.modal-x,.mcp-edit,#files-refresh,#files-close,
|
||||
.files-acts .iconbtn,.conv-acts .iconbtn{
|
||||
display:inline-flex;align-items:center;justify-content:center;line-height:1}
|
||||
#menubtn{padding:8px;line-height:0}
|
||||
#scrollbtn{padding:9px;line-height:0}
|
||||
.preset-add{padding:5px;line-height:0}
|
||||
#menubtn svg,#scrollbtn svg,.preset-add svg{display:block;margin:0 auto}
|
||||
.modal-x svg,.mcp-edit svg,#files-refresh svg,#files-close svg,.iconbtn svg{
|
||||
display:block;width:auto;height:auto}
|
||||
/* La pastille du fil d'Ariane des fichiers n'est plus un caractère : elle a la
|
||||
largeur de son dessin. */
|
||||
.files-ic{width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.files-ic svg{display:block}
|
||||
.files-row.isdir .files-ic{color:var(--accent)}
|
||||
/* « Vider la discussion » : icône + libellé, alignés comme un seul bloc. */
|
||||
#chat-actions-clear,.row>button>svg{vertical-align:-2px}
|
||||
.row>button:has(svg){display:inline-flex;align-items:center;gap:7px}
|
||||
/* Le « + » des sections : même carré que les autres commandes de la barre. */
|
||||
.preset-add{width:26px;height:26px;border-radius:8px}
|
||||
/* Étiquettes de groupe : même traitement que les titres de section — la
|
||||
maquette n'a qu'un style de titre, et le monospace en capitales de la charte
|
||||
précédente criait au milieu des formulaires. */
|
||||
.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{
|
||||
font-family:var(--font);text-transform:none;letter-spacing:0;
|
||||
font-size:12.5px;font-weight:600;color:var(--dim)}
|
||||
|
||||
/* --- Discussions et moniteur : le gabarit de carte des réglages ------
|
||||
La maquette posait ces deux blocs à plat, pleine largeur. Dans l'application
|
||||
réelle ils voisinent avec les sections de réglages, qui sont des CARTES : deux
|
||||
traitements pour deux listes côte à côte, et la barre latérale se lisait comme
|
||||
deux morceaux collés. On aligne donc sur la carte, qui est la forme dominante
|
||||
de la colonne. */
|
||||
#side-scroll{padding:0 12px 12px}
|
||||
#side-scroll>details>summary,#side-settings>summary{padding:0 2px}
|
||||
/* ⚠️ La gouttière INTÉRIEURE de la carte n'est pas décorative. En la mettant à
|
||||
zéro (le retrait extérieur venant désormais de #side-scroll), les lignes
|
||||
allaient d'un bord à l'autre : le chevron de chaque section tombait dans le
|
||||
congé de 20 px, donc il en débordait visuellement sur la première et la
|
||||
dernière ligne, et les filets de séparation venaient buter dans la courbe.
|
||||
Le rayon paraissait « trop fort » alors que c'est le contenu qui n'avait plus
|
||||
de quoi s'en écarter. On reprend le retrait d'avant la refonte (16 px), qui
|
||||
laisse au congé la place de tourner à vide. */
|
||||
#side-settings>.sidegroup{padding:2px 16px}
|
||||
@media (max-width:720px){
|
||||
#side-scroll{padding-left:calc(12px + env(safe-area-inset-left))}
|
||||
}
|
||||
#conv-search{width:100%;margin:0 0 8px}
|
||||
/* La liste EST la carte, au MÊME gabarit que .sidegroup (les sections de
|
||||
réglages) : fond de panneau, rayon de 20, ombre discrète. Les lignes sont
|
||||
rognées par overflow au lieu d'arrondir la première et la dernière à la main. */
|
||||
#conv-list{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||||
overflow:hidden;margin:0}
|
||||
.side .preset{border-radius:0;margin:0;padding:10px 12px}
|
||||
.side .preset:not(:last-child){border-bottom:1px solid var(--border)}
|
||||
.side .preset:last-child{border-bottom:0}
|
||||
/* Sélection : bande pleine largeur DANS la carte — un aplat suffit, un cadre
|
||||
ferait un second contour à l'intérieur du premier. */
|
||||
.side .preset.active{background:var(--row-bg-on)}
|
||||
.side .preset:hover:not(.active){background:color-mix(in srgb,var(--text) 4%,transparent)}
|
||||
.side .preset .conv-acts{background:var(--row-bg-on)}
|
||||
.side .preset.active .conv-acts{background:var(--row-bg-on)}
|
||||
.conv-empty{padding:12px}
|
||||
/* Moniteur machine : titre en étiquette de groupe, jauges dans une carte — même
|
||||
couple que « Paramètres » + sa liste dans les réglages. */
|
||||
#side-perf{padding:12px 12px 12px;padding-bottom:calc(12px + env(safe-area-inset-bottom))}
|
||||
.perf-head{margin:0 2px 7px;font-size:12.5px;font-weight:600;color:var(--dim)}
|
||||
.perf-body{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||||
padding:14px 15px 3px}
|
||||
/* ⚠️ Pas de remise à zéro sur :last-child — les jauges vivent dans DEUX
|
||||
conteneurs (les cartes graphiques, puis la mémoire vive). La dernière jauge
|
||||
de chaque conteneur s'y voyait retirer sa marge, et la mémoire vive venait
|
||||
se coller à la VRAM. C'est le rembourrage bas de la carte qui absorbe la
|
||||
dernière marge. */
|
||||
.perf-row{margin:0 0 12px}
|
||||
.perf-sub{padding:0 0 10px}
|
||||
+46
-26
@@ -546,51 +546,59 @@ func handleAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
_ = serviceAction("restart")
|
||||
}
|
||||
}
|
||||
k := readAPIKey()
|
||||
k, _ := effectiveAPIKeyErr()
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"set": k != "",
|
||||
"key": k,
|
||||
"masked": maskAPIKey(k),
|
||||
"port": LLMPort(),
|
||||
"host": localIP(),
|
||||
// Accès OpenAI PUBLIC via ajean.link (passthrough SNI, VPS aveugle) : si
|
||||
// activé, l'URL publique est https://<machine>.oai.ajean.link/v1.
|
||||
"oai_public": oaiPublicEnabled(),
|
||||
"machine": machineID(),
|
||||
// L'adresse à coller dans un client tiers. Calculée côté serveur (voir
|
||||
// web_public_url.go) : adresse publique enregistrée, sinon l'origine par
|
||||
// laquelle cette requête est arrivée — donc le domaine ou l'IP que
|
||||
// l'utilisateur a réellement tapés.
|
||||
"url": oaiBaseURL(r) + "/v1",
|
||||
"public_url": publicURL(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleOAIPublic pilote le drapeau d'accès OpenAI public (exposition via
|
||||
// ajean.link). GET renvoie l'état ; POST {enabled} l'active/coupe en direct
|
||||
// (aucun redémarrage : le démux du tunnel relit le drapeau à chaque connexion).
|
||||
func handleOAIPublic(w http.ResponseWriter, r *http.Request) {
|
||||
// handlePublicAddress lit et enregistre l'adresse publique de cette instance —
|
||||
// le nom de domaine ou l'IP par lesquels on l'atteint.
|
||||
//
|
||||
// GET → {ok, url, set}
|
||||
// POST {url:"ia.exemple.fr"} → {ok, url} normalisée, ou 400 avec la raison
|
||||
//
|
||||
// Elle remplace l'ancien interrupteur d'exposition via le relais de l'amont :
|
||||
// ce chemin exigeait un jeton de relais que ce fork ne permet plus d'obtenir, et
|
||||
// l'interrupteur ne pouvait donc qu'échouer. Aucun redémarrage ici — c'est un
|
||||
// libellé d'affichage, pas un réglage du moteur.
|
||||
func handlePublicAddress(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "requête illisible"})
|
||||
return
|
||||
}
|
||||
clean, err := setPublicURL(req.URL)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
if err := setOAIPublic(*req.Enabled); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "url": clean, "set": clean != ""})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"enabled": oaiPublicEnabled(),
|
||||
"machine": machineID(),
|
||||
})
|
||||
u := publicURL()
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "url": u, "set": u != ""})
|
||||
}
|
||||
|
||||
// localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du
|
||||
// trafic sortant), ou "localhost" à défaut. Sert à annoncer l'endpoint OpenAI
|
||||
// avec une adresse correcte sur le réseau local MÊME quand l'UI est atteinte via
|
||||
// le tunnel ajean.link (où location.hostname serait le domaine du relais, faux).
|
||||
// trafic sortant), ou "localhost" à défaut.
|
||||
//
|
||||
// ⚠️ Elle ne sert plus qu'à décrire l'écoute BRUTE DU MOTEUR dans `loki network`
|
||||
// (sys_network.go). L'endpoint annoncé à l'utilisateur, lui, est celui de Loki
|
||||
// et se calcule tout autrement (web_public_url.go) : en conteneur, cette IP est
|
||||
// celle du bridge Docker, que personne ne peut joindre.
|
||||
func localIP() string {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
@@ -695,6 +703,18 @@ func handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
||||
}
|
||||
|
||||
// handleMCPCatalog renvoie le catalogue embarqué de serveurs MCP connus.
|
||||
// Lecture seule : rien n'est installé ici, l'UI ne fait que préremplir la modale
|
||||
// d'ajout avec la commande de l'entrée choisie.
|
||||
func handleMCPCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := MCPCatalog()
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "catalog": entries})
|
||||
}
|
||||
|
||||
// handleMCPSave ajoute ou remplace un serveur MCP.
|
||||
func handleMCPSave(w http.ResponseWriter, r *http.Request) {
|
||||
var req mcpSaveReq
|
||||
|
||||
@@ -59,6 +59,63 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// requireCompletionKey protège la surface compatible OpenAI (/v1) avec la clé
|
||||
// des COMPLÉTIONS, et non avec la clé de pilotage. Trois raisons, dans l'ordre
|
||||
// d'importance :
|
||||
//
|
||||
// 1. Sémantique : c'est exactement la séparation décrite en tête de ce fichier
|
||||
// — on veut pouvoir donner à une app tierce l'accès aux complétions sans lui
|
||||
// donner le droit de redémarrer la machine.
|
||||
// 2. Technique : un client OpenAI n'a qu'UN en-tête Authorization. S'il y met la
|
||||
// clé des complétions, une garde de pilotage le refuse ; s'il y met celle de
|
||||
// pilotage, llama-server (qui reçoit l'en-tête tel quel) la refuse. Les deux
|
||||
// gardes ne peuvent pas être satisfaites en même temps.
|
||||
// 3. Format : les SDK OpenAI lisent body.error.message ; un {"error":"…"} plat
|
||||
// leur fait afficher un message vide.
|
||||
func requireCompletionKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Préflight CORS : un OPTIONS ne porte JAMAIS d'Authorization. Le refuser
|
||||
// casse tout client tiers qui tourne dans un navigateur (playground web,
|
||||
// interface de chat hébergée ailleurs). llama-server répond lui-même le
|
||||
// préflight, ses en-têtes CORS traversent le proxy tels quels.
|
||||
if r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Trafic venu du tunnel du relais : la surface OpenAI n'y est ouverte que
|
||||
// si le drapeau historique l'autorise. Sans lui, le tunnel ne servait
|
||||
// cette surface que par son front TLS dédié ; la monter sur le mux ne
|
||||
// doit pas l'ouvrir en douce par le canal HTTP.
|
||||
if r.Header.Get(viaTunnelHeader) == "tunnel" && !oaiPublicEnabled() {
|
||||
sendOAIError(w, http.StatusNotFound,
|
||||
"chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found")
|
||||
return
|
||||
}
|
||||
key, err := effectiveAPIKeyErr()
|
||||
if err != nil {
|
||||
// On ne sait pas si une clé protège cet endpoint : on ferme.
|
||||
sendOAIError(w, http.StatusServiceUnavailable,
|
||||
"configuration illisible — réessaie dans un instant", "api_error", "service_unavailable")
|
||||
return
|
||||
}
|
||||
if key == "" {
|
||||
// Aucune clé : endpoint ouvert, comme llama-server sans --api-key. Le
|
||||
// panneau Accès OpenAI l'affiche en rouge, et cmdWeb le crie au
|
||||
// démarrage — c'est le prix d'un réglage local sans friction.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !checkBearer(r, key) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="loki"`)
|
||||
sendOAIError(w, http.StatusUnauthorized,
|
||||
"Clé API invalide ou absente — envoie l'en-tête Authorization: Bearer <clé>.",
|
||||
"invalid_request_error", "invalid_api_key")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// checkBearer reports whether the request carries the expected key as an
|
||||
// "Authorization: Bearer <clé>" header. La comparaison est à temps constant.
|
||||
// PAS de repli ?key=<clé> en query string : une clé dans l'URL finit dans les
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// web_engine.go — panneau « Moteur de l'image » : voir la version de llama.cpp
|
||||
// qui tourne, savoir s'il en existe une plus récente, l'installer, et revenir
|
||||
// en arrière si elle ne convient pas.
|
||||
//
|
||||
// GET /api/engine → version courante, versions installées, variante
|
||||
// POST /api/engine/check → dernier build publié vs celui qui tourne
|
||||
// POST /api/engine/update → job : téléchargement + test à blanc + bascule
|
||||
// POST /api/engine/use → bascule vers une version déjà installée
|
||||
// POST /api/engine/remove → supprime une version installée
|
||||
//
|
||||
// Jusqu'ici, mettre à jour llama.cpp imposait de reconstruire l'image de Loki —
|
||||
// donc un rebuild complet et un redéploiement pour un composant qui publie
|
||||
// plusieurs versions par jour. Le panneau ne le disait même pas : il annonçait
|
||||
// « rien à mettre à jour ici ». Ces quatre routes suppriment ce détour.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// engineUseImage est la valeur de `tag` qui désigne le moteur d'origine, celui
|
||||
// que l'image Docker fournit dans /app. C'est le retour arrière garanti : il ne
|
||||
// dépend d'aucun téléchargement et ne peut pas manquer.
|
||||
const engineUseImage = "image"
|
||||
|
||||
// handleEngineStatus décrit le moteur courant et ce qui est installé à côté.
|
||||
func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
|
||||
bin := currentEngineBin()
|
||||
build, commit := engineBuildOf(bin)
|
||||
imageBin := providedEngineBin()
|
||||
|
||||
source := "custom"
|
||||
switch {
|
||||
case imageBin != "" && samePath(bin, imageBin):
|
||||
source = "image"
|
||||
case engineOwns(bin):
|
||||
source = "downloaded"
|
||||
case prebuiltOwns(bin):
|
||||
source = "prebuilt"
|
||||
}
|
||||
|
||||
var installed []map[string]any
|
||||
for _, tag := range engineInstalled() {
|
||||
p := engineBinFor(tag)
|
||||
installed = append(installed, map[string]any{
|
||||
"tag": tag,
|
||||
"bin": p,
|
||||
"build": engineTagBuild(tag),
|
||||
"in_use": samePath(p, bin),
|
||||
})
|
||||
}
|
||||
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"supported": engineOCISupported(),
|
||||
"variant": engineVariant(),
|
||||
"repo": ociHost() + "/" + ociEngineRepo,
|
||||
"bin": bin,
|
||||
"build": build,
|
||||
"commit": commit,
|
||||
"source": source,
|
||||
"image_bin": imageBin,
|
||||
"dir": engineDir(),
|
||||
"installed": installed,
|
||||
"job": lcJobSnapshot(0, false),
|
||||
})
|
||||
}
|
||||
|
||||
// handleEngineCheck interroge le registre : quel est le dernier build publié
|
||||
// pour cette variante, et sommes-nous en retard ? Synchrone, quelques secondes.
|
||||
func handleEngineCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if !engineOCISupported() {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
|
||||
return
|
||||
}
|
||||
variant := engineVariant()
|
||||
build, tag, err := engineOCILatest(variant)
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cur, _ := engineBuildOf(currentEngineBin())
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"variant": variant,
|
||||
"latest": build,
|
||||
"tag": tag,
|
||||
"current": cur,
|
||||
// Un build courant inconnu (0) ne prouve pas qu'on est à jour : on
|
||||
// propose alors la mise à jour plutôt que d'affirmer le contraire.
|
||||
"update": build == 0 || cur == 0 || build > cur,
|
||||
})
|
||||
}
|
||||
|
||||
// handleEngineUpdate lance le job de mise à jour. {tag} permet d'épingler une
|
||||
// version précise ; sans lui, on prend la dernière publiée pour la variante.
|
||||
func handleEngineUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !engineOCISupported() {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
tag := strings.TrimSpace(req.Tag)
|
||||
if err := startLcJob("engine", func() { engineRunUpdate(tag) }); err != nil {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func engineUnsupportedWhy() string {
|
||||
return "les images officielles llama.cpp n'existent que pour Linux x86-64 et arm64 ; " +
|
||||
"sur cette machine, utilise « llama.cpp précompilé » ou « llama.cpp compilé »."
|
||||
}
|
||||
|
||||
// engineRunUpdate : corps du job. Télécharger, ESSAYER, puis seulement basculer.
|
||||
// L'ordre compte — le seul vrai risque ici est un moteur plus récent que le
|
||||
// runtime CUDA de l'image, et on le découvre au test à blanc, moteur courant
|
||||
// encore intact.
|
||||
func engineRunUpdate(tag string) {
|
||||
variant := engineVariant()
|
||||
ancien := currentEngineBin()
|
||||
if tag == "" {
|
||||
lcPhase("recherche de la dernière version publiée…")
|
||||
build, latest, err := engineOCILatest(variant)
|
||||
if err != nil {
|
||||
lcFail(err)
|
||||
return
|
||||
}
|
||||
tag = latest
|
||||
if build > 0 {
|
||||
if cur, _ := engineBuildOf(currentEngineBin()); cur == build {
|
||||
lcDone(fmt.Sprintf("déjà à jour (build %d)", build))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
lcAppend("variante : " + variant + " — version visée : " + tag)
|
||||
|
||||
bin, err := engineOCIInstall(tag, lcAppend, lcPhase)
|
||||
if err != nil {
|
||||
lcFail(err)
|
||||
return
|
||||
}
|
||||
|
||||
lcPhase("essai du nouveau moteur…")
|
||||
if err := engineSmokeTest(bin, ancien); err != nil {
|
||||
// On ne garde pas un moteur inutilisable : il encombrerait la liste et
|
||||
// inviterait à le sélectionner.
|
||||
_ = os.RemoveAll(filepath.Dir(bin))
|
||||
lcFail(fmt.Errorf("%w\n\nLe moteur courant n'a PAS été touché. Cette version demande sans doute un runtime CUDA plus récent que celui de l'image : reconstruis l'image avec un LLAMACPP_IMAGE récent, ou épingle un build antérieur", err))
|
||||
return
|
||||
}
|
||||
if build, commit := engineBuildOf(bin); build > 0 {
|
||||
lcAppend(fmt.Sprintf("moteur testé : build %d (%s)", build, commit))
|
||||
}
|
||||
|
||||
if err := engineSwitchTo(bin); err != nil {
|
||||
lcFail(err)
|
||||
return
|
||||
}
|
||||
// On garde la version qu'on vient de poser ; les précédentes ne servent plus
|
||||
// (le retour arrière se fait sur le moteur de l'image, qui, lui, est intact).
|
||||
enginePrune(map[string]bool{filepath.Base(filepath.Dir(bin)): true}, lcAppend)
|
||||
lcDone("moteur mis à jour (" + tag + ")")
|
||||
}
|
||||
|
||||
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
|
||||
func engineSwitchTo(bin string) error {
|
||||
wasUp := serviceIsActive()
|
||||
if wasUp {
|
||||
lcPhase("arrêt du moteur…")
|
||||
if err := serviceAction("stop"); err != nil {
|
||||
lcAppend("[warn] arrêt du moteur : " + err.Error())
|
||||
}
|
||||
}
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
if wasUp {
|
||||
_ = serviceAction("start")
|
||||
}
|
||||
return fmt.Errorf("moteur installé mais échec d'écriture de BIN : %w", err)
|
||||
}
|
||||
lcAppend("BIN → " + bin)
|
||||
if wasUp {
|
||||
lcPhase("redémarrage du moteur…")
|
||||
if err := serviceAction("start"); err != nil {
|
||||
lcAppend("[warn] redémarrage du moteur : " + err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleEngineUse bascule vers une version DÉJÀ installée, ou revient au moteur
|
||||
// de l'image ({tag:"image"}). Aucun réseau : c'est la sortie de secours quand une
|
||||
// mise à jour s'avère décevante.
|
||||
func handleEngineUse(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
tag := strings.TrimSpace(req.Tag)
|
||||
|
||||
var bin string
|
||||
if tag == engineUseImage {
|
||||
if bin = providedEngineBin(); bin == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "cette installation n'a pas de moteur d'image (LOKI_ENGINE_BIN)"})
|
||||
return
|
||||
}
|
||||
} else if bin = engineBinFor(tag); bin == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
|
||||
return
|
||||
}
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if serviceIsActive() {
|
||||
_ = serviceAction("restart")
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "bin": bin})
|
||||
}
|
||||
|
||||
// handleEngineRemove supprime une version téléchargée. On refuse de retirer
|
||||
// celle qui tourne : ça laisserait une configuration pointant dans le vide, et
|
||||
// le moteur ne redémarrerait plus.
|
||||
func handleEngineRemove(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
tag := filepath.Base(strings.TrimSpace(req.Tag))
|
||||
bin := engineBinFor(tag)
|
||||
if bin == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
|
||||
return
|
||||
}
|
||||
if samePath(bin, currentEngineBin()) {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "c'est le moteur utilisé — bascule d'abord sur une autre version"})
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
+54
-28
@@ -11,7 +11,13 @@ package loki
|
||||
// Le téléchargement existait déjà (handleChatFile, web_upload.go). Ce fichier
|
||||
// ajoute les deux verbes qui manquaient : LISTER et SUPPRIMER.
|
||||
//
|
||||
// Tout est borné par workspaceRel (web_upload.go), qui résout les liens
|
||||
// Le panneau s'ouvre sur le dossier de la DISCUSSION active (chat_convfiles.go)
|
||||
// et n'en sort pas : les fichiers appartiennent à la discussion où ils sont nés,
|
||||
// changer de discussion change ce qu'on voit. `scope=legacy` ouvre la racine du
|
||||
// dossier de travail — les fichiers d'avant ce rangement, que la migration n'a
|
||||
// pas su rattacher à une discussion.
|
||||
//
|
||||
// Tout est borné par relWithin (chat_convfiles.go), qui résout les liens
|
||||
// symboliques des DEUX côtés avant de comparer : un lien posé dans le dossier
|
||||
// de travail ne peut pas servir d'échappatoire vers le reste du disque.
|
||||
|
||||
@@ -29,7 +35,7 @@ import (
|
||||
// qui aide à décider.
|
||||
type wsEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"` // relatif au dossier de travail, séparateurs /
|
||||
Path string `json:"path"` // relatif à la racine du panneau, séparateurs /
|
||||
Dir bool `json:"dir"`
|
||||
Size int64 `json:"size"`
|
||||
Mod int64 `json:"mod"` // date de modification, epoch en secondes
|
||||
@@ -51,17 +57,17 @@ var wsImageExt = map[string]bool{
|
||||
// annoncer une taille approchée (Items le dit).
|
||||
const wsWalkLimit = 20000
|
||||
|
||||
// wsResolve traduit un chemin RELATIF venu du client en chemin absolu vérifié.
|
||||
// La chaîne vide et "." désignent la racine du dossier de travail — cas que
|
||||
// workspaceRel rejette (il renvoie false sur "."), d'où ce sas.
|
||||
func wsResolve(rel string) (string, bool) {
|
||||
// wsResolve traduit un chemin RELATIF venu du client en chemin absolu vérifié,
|
||||
// sous la racine du panneau. La chaîne vide et "." désignent cette racine — cas
|
||||
// que relWithin rejette (il renvoie false sur "."), d'où ce sas.
|
||||
func wsResolve(root, rel string) (string, bool) {
|
||||
rel = strings.TrimSpace(rel)
|
||||
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
|
||||
if rel == "" || rel == "." {
|
||||
return agentWorkspace(), true
|
||||
return root, true
|
||||
}
|
||||
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
|
||||
if _, ok := workspaceRel(abs); !ok {
|
||||
abs := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if _, ok := relWithin(root, abs); !ok {
|
||||
return "", false
|
||||
}
|
||||
return abs, true
|
||||
@@ -102,17 +108,21 @@ func dirSize(root string) (total int64, count int) {
|
||||
return total, count
|
||||
}
|
||||
|
||||
// handleChatFiles liste un dossier du workspace.
|
||||
// handleChatFiles liste un dossier de la discussion active.
|
||||
//
|
||||
// GET /api/chat/files?dir=<relatif>
|
||||
// → {ok, dir, parent, entries:[…], total, count}
|
||||
// GET /api/chat/files?dir=<relatif>[&scope=legacy]
|
||||
// → {ok, dir, parent, entries:[…], total, count, scope, legacy}
|
||||
//
|
||||
// `total` et `count` portent sur le dossier de travail ENTIER, pas sur le
|
||||
// dossier affiché : c'est l'occupation disque de l'agent, l'information qu'on
|
||||
// veut avoir sous les yeux en faisant le ménage.
|
||||
// `total` et `count` portent sur le dossier ENTIER de la discussion, pas sur le
|
||||
// dossier affiché : c'est ce que cette discussion occupe sur le disque, donc ce
|
||||
// qu'on libère en la supprimant.
|
||||
//
|
||||
// `legacy` compte ce qui reste à la racine du dossier de travail, hors
|
||||
// discussions : l'UI n'offre le détour « hors discussion » que si ce compte est
|
||||
// non nul, et le bouton disparaît une fois le ménage fait.
|
||||
func handleChatFiles(w http.ResponseWriter, r *http.Request) {
|
||||
rel := r.URL.Query().Get("dir")
|
||||
abs, ok := wsResolve(rel)
|
||||
root, legacy := filesRoot(r.URL.Query().Get("scope"))
|
||||
abs, ok := wsResolve(root, r.URL.Query().Get("dir"))
|
||||
if !ok {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
|
||||
return
|
||||
@@ -128,10 +138,15 @@ func handleChatFiles(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cur, _ := workspaceRel(abs) // "" à la racine
|
||||
cur, _ := relWithin(root, abs) // "" à la racine
|
||||
entries := []wsEntry{}
|
||||
for _, e := range ents {
|
||||
name := e.Name()
|
||||
// Vue « hors discussion » : le dossier des discussions n'y a pas sa place,
|
||||
// il est déjà accessible discussion par discussion.
|
||||
if legacy && cur == "" && name == convFilesRoot {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -164,40 +179,51 @@ func handleChatFiles(w http.ResponseWriter, r *http.Request) {
|
||||
parent = cur[:i]
|
||||
}
|
||||
}
|
||||
total, count := dirSize(agentWorkspace())
|
||||
total, count := dirSize(root)
|
||||
scope := ""
|
||||
if legacy {
|
||||
scope = "legacy"
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true, "dir": cur, "parent": parent, "at_root": cur == "",
|
||||
"entries": entries, "total": total, "count": count,
|
||||
"root": agentWorkspace(),
|
||||
"root": root, "scope": scope, "legacy": legacyCount(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleChatFileDelete supprime un fichier ou un dossier du workspace.
|
||||
// handleChatFileDelete supprime un fichier ou un dossier de la discussion.
|
||||
//
|
||||
// POST /api/chat/file/delete {"path":"rapport.md"}
|
||||
// POST /api/chat/file/delete {"path":"rapport.md"[,"scope":"legacy"]}
|
||||
// → {ok, freed}
|
||||
//
|
||||
// La racine du dossier de travail est refusée : « tout supprimer » n'est pas un
|
||||
// geste qu'on veut à un clic de distance, et l'agent perdrait aussi le dossier
|
||||
// dans lequel il écrit.
|
||||
// La racine est refusée : « tout supprimer » n'est pas un geste qu'on veut à un
|
||||
// clic de distance, et l'agent perdrait aussi le dossier dans lequel il écrit.
|
||||
// Le dossier des discussions l'est aussi — depuis la vue « hors discussion », il
|
||||
// emporterait les fichiers de TOUTES les discussions d'un seul clic.
|
||||
func handleChatFileDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Path string `json:"path"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if strings.TrimSpace(req.Path) == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
|
||||
return
|
||||
}
|
||||
abs, ok := wsResolve(req.Path)
|
||||
root, legacy := filesRoot(req.Scope)
|
||||
abs, ok := wsResolve(root, req.Path)
|
||||
if !ok {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
|
||||
return
|
||||
}
|
||||
if abs == agentWorkspace() {
|
||||
if abs == root {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "le dossier de travail lui-même ne se supprime pas"})
|
||||
return
|
||||
}
|
||||
if legacy && abs == filepath.Join(agentWorkspace(), convFilesRoot) {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "les dossiers des discussions se suppriment discussion par discussion"})
|
||||
return
|
||||
}
|
||||
st, err := os.Lstat(abs)
|
||||
if err != nil {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "introuvable"})
|
||||
|
||||
@@ -10,11 +10,17 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withWorkspace pointe le dossier de travail sur un dossier temporaire. Le
|
||||
// chemin est mémoïsé par un sync.Once (chat_workspace.go), donc on force la
|
||||
// variable directement — c'est le seul moyen d'isoler ces tests.
|
||||
// withWorkspace pointe le dossier de travail sur un dossier temporaire et rend
|
||||
// le dossier de la discussion ACTIVE — celui où atterrissent les fichiers et
|
||||
// que le panneau ouvre. Le chemin de la racine est mémoïsé par un sync.Once
|
||||
// (chat_workspace.go), donc on force la variable directement : c'est le seul
|
||||
// moyen d'isoler ces tests.
|
||||
//
|
||||
// testHome donne au passage une base à part : la discussion active s'y lit, et
|
||||
// aucun test ne doit toucher celle de la machine qui les fait tourner.
|
||||
func withWorkspace(t *testing.T) string {
|
||||
t.Helper()
|
||||
testHome(t)
|
||||
dir := t.TempDir()
|
||||
// EvalSymlinks : sur macOS /var est un lien vers /private/var, et
|
||||
// workspaceRel compare des chemins résolus. Sans ça tout serait « hors du
|
||||
@@ -29,7 +35,7 @@ func withWorkspace(t *testing.T) string {
|
||||
prevPath := agentWorkspace()
|
||||
workspacePath = dir
|
||||
t.Cleanup(func() { workspacePath = prevPath })
|
||||
return dir
|
||||
return convWorkspace()
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, body string) {
|
||||
|
||||
@@ -168,6 +168,15 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) {
|
||||
// Compilé », qui ne désignent rien ici.
|
||||
out["provided"] = engineProvided()
|
||||
out["provided_bin"] = providedEngineBin()
|
||||
// Moteur téléchargé depuis l'image officielle llama.cpp (web_engine.go) : ni
|
||||
// « précompilé », ni « compilé », ni « personnalisé ». Sans cette information,
|
||||
// le bandeau d'état le rangeait dans « personnalisé », ce qui laisse croire à
|
||||
// un bricolage alors que c'est la voie de mise à jour normale en conteneur.
|
||||
out["engine"] = map[string]any{
|
||||
"dir": engineDir(),
|
||||
"in_use": engineOwns(cfgBin),
|
||||
"tag": engineTagOf(cfgBin),
|
||||
}
|
||||
|
||||
plan := detectBuildPlan()
|
||||
out["plan"] = map[string]any{
|
||||
@@ -224,8 +233,8 @@ func refuseIfProvided(w http.ResponseWriter) bool {
|
||||
return false
|
||||
}
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "Le moteur est fourni par l'image Docker (" + bin +
|
||||
") : il n'y a rien à installer ici, et l'image ne contient ni cmake ni compilateur. " +
|
||||
"Pour changer de moteur, reconstruis l'image avec un autre build-arg LLAMACPP_IMAGE."})
|
||||
") : il n'y a rien à compiler ici, et l'image ne contient ni cmake ni compilateur. " +
|
||||
"Pour le mettre à jour, utilise « mettre à jour le moteur » dans Réglages → Moteur."})
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package loki
|
||||
|
||||
// web_public_url.go — l'adresse par laquelle on atteint cette instance.
|
||||
//
|
||||
// Le panneau « Accès OpenAI » doit afficher une adresse qu'on puisse coller
|
||||
// telle quelle dans un client tiers. Il annonçait auparavant l'adresse du
|
||||
// MOTEUR (http://<ip>:8080/v1), qui n'est joignable dans aucun déploiement en
|
||||
// conteneur — le port n'y est pas publié — et dont l'IP, en conteneur, est
|
||||
// celle du bridge Docker. Deux façons de faire mieux, dans cet ordre :
|
||||
//
|
||||
// 1. l'adresse publique enregistrée, quand l'utilisateur passe par un nom de
|
||||
// domaine ou un reverse proxy et l'a saisie une fois pour toutes ;
|
||||
// 2. sinon, l'origine de la requête elle-même — c'est-à-dire exactement le
|
||||
// domaine ou l'IP qu'on a tapés pour ouvrir l'interface.
|
||||
//
|
||||
// Le calcul est ici, en Go, et pas dans le navigateur : une seule règle de
|
||||
// priorité, réutilisable par la CLI et les journaux, et plus de construction
|
||||
// d'URL à coups de concaténation côté client — c'est ce qui avait produit
|
||||
// l'adresse fantôme.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// publicURL renvoie l'adresse publique enregistrée, normalisée, ou "".
|
||||
func publicURL() string { return getStr(bkState, "public_url") }
|
||||
|
||||
// setPublicURL normalise puis enregistre l'adresse publique. Une chaîne vide
|
||||
// efface le réglage (on repasse alors sur l'origine de la requête). Renvoie la
|
||||
// valeur réellement stockée.
|
||||
func setPublicURL(raw string) (string, error) {
|
||||
clean, err := normalizePublicURL(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := putStr(bkState, "public_url", clean); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// normalizePublicURL ramène une saisie humaine à une origine canonique :
|
||||
// "schéma://hôte[:port]", sans slash final. Pure, donc testable sans E/S.
|
||||
//
|
||||
// Les refus sont volontairement bavards : cette valeur est recopiée dans la
|
||||
// configuration d'un autre logiciel, une erreur silencieuse s'y paierait en
|
||||
// « ça ne marche pas » sans indice.
|
||||
func normalizePublicURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil // effacement : ce n'est pas une erreur
|
||||
}
|
||||
// Sans schéma, url.Parse lit « exemple.fr:8090 » comme un schéma « exemple.fr ».
|
||||
// Quelqu'un qui tape un nom de domaine nu veut https.
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("adresse illisible : %s", raw)
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", fmt.Errorf("adresse en %s:// — seuls http:// et https:// sont acceptés", scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("adresse sans nom de machine")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("l'adresse ne doit pas porter d'identifiants (%s@…)", u.User.Username())
|
||||
}
|
||||
if u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", fmt.Errorf("l'adresse ne doit porter ni paramètre ni ancre")
|
||||
}
|
||||
// Chemin : on tolère le « /v1 » que l'utilisateur recopie naturellement
|
||||
// depuis l'adresse affichée, et rien d'autre — le reste supposerait que le
|
||||
// reverse proxy réécrit les chemins, ce qu'on ne peut pas vérifier d'ici.
|
||||
switch strings.TrimSuffix(u.Path, "/") {
|
||||
case "", "/v1":
|
||||
default:
|
||||
return "", fmt.Errorf("l'adresse ne doit pas contenir de chemin (%s) : Loki sert /v1 à la racine de son port", u.Path)
|
||||
}
|
||||
host := u.Host
|
||||
if h, port, err := net.SplitHostPort(host); err == nil {
|
||||
if h == "" {
|
||||
return "", fmt.Errorf("adresse sans nom de machine")
|
||||
}
|
||||
if n, err := net.LookupPort("tcp", port); err != nil || n == 0 {
|
||||
return "", fmt.Errorf("port invalide : %s", port)
|
||||
}
|
||||
} else if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||
// IPv6 littéral doit être entre crochets, sinon on ne sait pas où
|
||||
// s'arrête l'adresse et où commence le port.
|
||||
return "", fmt.Errorf("adresse IPv6 à écrire entre crochets : [%s]", host)
|
||||
}
|
||||
return scheme + "://" + host, nil
|
||||
}
|
||||
|
||||
// requestBase renvoie l'origine par laquelle CETTE requête a atteint Loki :
|
||||
// "schéma://hôte[:port]", sans slash final.
|
||||
//
|
||||
// L'hôte vient de r.Host, c'est-à-dire de l'en-tête Host du navigateur : le
|
||||
// domaine réellement tapé, correct même derrière un reverse proxy qui ne
|
||||
// réécrit pas Host. On n'honore PAS X-Forwarded-Host, qui élargirait la surface
|
||||
// de confiance sans rien apporter ici.
|
||||
//
|
||||
// Le schéma, lui, a besoin de X-Forwarded-Proto : derrière un proxy qui termine
|
||||
// le TLS, Loki ne voit qu'une requête en clair et annoncerait http:// pour une
|
||||
// interface servie en https://. Ces en-têtes sont forgeables quand il n'y a pas
|
||||
// de proxy devant — sans conséquence : le résultat n'est qu'une chaîne affichée
|
||||
// à un propriétaire déjà authentifié, jamais une décision d'accès.
|
||||
func requestBase(r *http.Request) string {
|
||||
scheme := "http"
|
||||
switch {
|
||||
case r.TLS != nil:
|
||||
scheme = "https"
|
||||
}
|
||||
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
||||
first := strings.ToLower(strings.TrimSpace(strings.Split(fwd, ",")[0]))
|
||||
if first == "http" || first == "https" {
|
||||
scheme = first
|
||||
}
|
||||
}
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
// oaiBaseURL est l'adresse à coller dans un client tiers : l'adresse publique
|
||||
// enregistrée si elle existe, sinon celle par laquelle la requête est arrivée.
|
||||
func oaiBaseURL(r *http.Request) string {
|
||||
if u := publicURL(); u != "" {
|
||||
return u
|
||||
}
|
||||
return requestBase(r)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package loki
|
||||
|
||||
// L'adresse annoncée dans le panneau « Accès OpenAI » est ce qu'on recopie dans
|
||||
// la configuration d'un autre logiciel : une valeur fausse ne se voit pas, elle
|
||||
// se paie en « ça ne marche pas » sans indice. D'où ces tests.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalisePublicURL(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
saisie, veut string
|
||||
refus bool
|
||||
}{
|
||||
// Un nom nu vaut https : personne ne tape le schéma.
|
||||
{saisie: "ia.exemple.fr", veut: "https://ia.exemple.fr"},
|
||||
{saisie: " ia.exemple.fr ", veut: "https://ia.exemple.fr"},
|
||||
{saisie: "http://192.168.1.20:8090", veut: "http://192.168.1.20:8090"},
|
||||
{saisie: "192.168.1.20:8090", veut: "https://192.168.1.20:8090"},
|
||||
{saisie: "HTTPS://Exemple.fr/", veut: "https://Exemple.fr"},
|
||||
// Le « /v1 » recopié depuis l'adresse affichée est toléré et retiré :
|
||||
// c'est le geste naturel, pas une faute.
|
||||
{saisie: "https://ia.exemple.fr/v1", veut: "https://ia.exemple.fr"},
|
||||
{saisie: "https://ia.exemple.fr/v1/", veut: "https://ia.exemple.fr"},
|
||||
{saisie: "[::1]:8090", veut: "https://[::1]:8090"},
|
||||
// Vide = effacement, pas erreur.
|
||||
{saisie: " ", veut: ""},
|
||||
// Refus, chacun pour une raison qu'on veut pouvoir expliquer.
|
||||
{saisie: "ftp://ia.exemple.fr", refus: true},
|
||||
{saisie: "https://ia.exemple.fr/base", refus: true},
|
||||
{saisie: "https://user:mdp@ia.exemple.fr", refus: true},
|
||||
{saisie: "https://ia.exemple.fr?x=1", refus: true},
|
||||
{saisie: "https://ia.exemple.fr:port", refus: true},
|
||||
} {
|
||||
got, err := normalizePublicURL(c.saisie)
|
||||
if c.refus {
|
||||
if err == nil {
|
||||
t.Errorf("%q accepté → %q, refus attendu", c.saisie, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%q refusé : %v", c.saisie, err)
|
||||
continue
|
||||
}
|
||||
if got != c.veut {
|
||||
t.Errorf("%q → %q, attendu %q", c.saisie, got, c.veut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// L'origine de la requête : le domaine réellement tapé, et le schéma que le
|
||||
// reverse proxy annonce. Sans X-Forwarded-Proto, une interface servie en https
|
||||
// afficherait une adresse en http — inutilisable telle quelle.
|
||||
func TestRequestBase(t *testing.T) {
|
||||
for _, c := range []struct{ nom, hôte, proto, veut string }{
|
||||
{"ip et port", "192.168.1.20:8090", "", "http://192.168.1.20:8090"},
|
||||
{"domaine derrière un proxy TLS", "ia.exemple.fr", "https", "https://ia.exemple.fr"},
|
||||
{"chaîne de proxys", "ia.exemple.fr", "https, http", "https://ia.exemple.fr"},
|
||||
{"en-tête douteux ignoré", "ia.exemple.fr", "gopher", "http://ia.exemple.fr"},
|
||||
} {
|
||||
t.Run(c.nom, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "http://placeholder/api/apikey", nil)
|
||||
r.Host = c.hôte
|
||||
if c.proto != "" {
|
||||
r.Header.Set("X-Forwarded-Proto", c.proto)
|
||||
}
|
||||
if got := requestBase(r); got != c.veut {
|
||||
t.Errorf("requestBase = %q, attendu %q", got, c.veut)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// L'adresse enregistrée l'emporte sur celle du navigateur : c'est tout l'objet
|
||||
// du champ, pour le cas du reverse proxy où Loki ne voit qu'un appel interne.
|
||||
func TestAdresseAffichee(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
lire := func() map[string]any {
|
||||
r := httptest.NewRequest("GET", "http://placeholder/api/apikey", nil)
|
||||
r.Host = "ia.exemple.fr"
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
rec := httptest.NewRecorder()
|
||||
handleAPIKey(rec, r) // GET seulement : le POST redémarre le moteur
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if got := lire()["url"]; got != "https://ia.exemple.fr/v1" {
|
||||
t.Errorf("sans adresse enregistrée : url = %v", got)
|
||||
}
|
||||
if _, err := setPublicURL("autre.exemple.fr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := lire()
|
||||
if out["url"] != "https://autre.exemple.fr/v1" {
|
||||
t.Errorf("avec adresse enregistrée : url = %v", out["url"])
|
||||
}
|
||||
if out["public_url"] != "https://autre.exemple.fr" {
|
||||
t.Errorf("public_url = %v", out["public_url"])
|
||||
}
|
||||
// Effacement : on repasse sur l'origine de la requête.
|
||||
if _, err := setPublicURL(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := lire()["url"]; got != "https://ia.exemple.fr/v1" {
|
||||
t.Errorf("après effacement : url = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,14 @@ func cmdWeb(args []string) error {
|
||||
} else {
|
||||
fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]"))
|
||||
}
|
||||
// Même avertissement pour la surface des complétions : depuis qu'elle est
|
||||
// servie ici, elle est joignable partout où l'interface l'est.
|
||||
if k, _ := effectiveAPIKeyErr(); k == "" {
|
||||
fmt.Printf("%s endpoint OpenAI /v1 NON protégé (aucune clé) — quiconque joint cette adresse peut utiliser le modèle :\n", yellow("[!]"))
|
||||
fmt.Printf(" %s\n", bold("loki set-api-key"))
|
||||
} else {
|
||||
fmt.Printf("%s endpoint OpenAI : http://%s/v1 (clé requise)\n", green("[oai]"), addr)
|
||||
}
|
||||
|
||||
// Accès distant : le tunnel vers le relais est ouvert ICI, dans le process qui
|
||||
// sert déjà l'UI, et avec le MÊME mux. C'est la condition d'une conversation
|
||||
@@ -73,7 +81,9 @@ func cmdWeb(args []string) error {
|
||||
// ReadHeaderTimeout : sans lui, une connexion qui n'envoie jamais sa requête
|
||||
// immobilise une goroutine pour toujours — et ce port écoute sur 0.0.0.0.
|
||||
// Surtout PAS de WriteTimeout ici : il couperait les flux SSE du chat, qui
|
||||
// restent ouverts aussi longtemps que l'utilisateur regarde la page.
|
||||
// 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}
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
@@ -93,6 +103,9 @@ func newWebMux() *http.ServeMux {
|
||||
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
|
||||
// .part orphelin non reprenable : on nettoie au démarrage.
|
||||
cleanStalePartFiles()
|
||||
// Les fichiers des versions précédentes vivaient dans un pot commun : on les
|
||||
// range dans le dossier de leur discussion (une fois par process).
|
||||
migrateConvFiles()
|
||||
// Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent
|
||||
// en mémoire, aucun .part ne survit utilement à l'arrêt du process.
|
||||
cleanStaleUploadParts()
|
||||
@@ -116,10 +129,10 @@ func newWebMux() *http.ServeMux {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write(b)
|
||||
})
|
||||
// Polices embarquées (Bricolage Grotesque + IBM Plex Mono, sous-ensemble
|
||||
// latin). Servies par Loki et non par Google Fonts : une instance locale ou
|
||||
// derrière un réseau fermé doit s'afficher correctement sans appeler un tiers,
|
||||
// et sans lui signaler qui consulte l'interface.
|
||||
// Polices embarquées (Inter + JetBrains Mono, sous-ensemble latin). Servies
|
||||
// par Loki et non par Google Fonts : une instance locale ou derrière un
|
||||
// réseau fermé doit s'afficher correctement sans appeler un tiers, et sans
|
||||
// lui signaler qui consulte l'interface.
|
||||
mux.HandleFunc("/fonts/", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := path.Base(r.URL.Path)
|
||||
if !strings.HasSuffix(name, ".woff2") {
|
||||
@@ -137,6 +150,12 @@ func newWebMux() *http.ServeMux {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Write(b)
|
||||
})
|
||||
// Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers
|
||||
// llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de
|
||||
// domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le
|
||||
// moteur sur le réseau. Il n'est PAS derrière requireWebAuth : il a sa propre
|
||||
// clé, celle des complétions (web_auth.go).
|
||||
mountOAI(mux)
|
||||
// api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go).
|
||||
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
||||
api("/api/ping", handlePing)
|
||||
@@ -170,6 +189,13 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés
|
||||
api("/api/llamacpp/prebuilt/check", handleLlamacppPrebuiltCheck) // dernière release officielle vs installée
|
||||
api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées
|
||||
// Moteur livré par l'image : mise à jour SANS reconstruire l'image, en
|
||||
// extrayant /app de l'image officielle llama.cpp (voir web_engine.go).
|
||||
api("/api/engine", handleEngineStatus) // version qui tourne + versions installées
|
||||
api("/api/engine/check", handleEngineCheck) // dernier build publié vs courant
|
||||
api("/api/engine/update", handleEngineUpdate) // job : téléchargement + essai + bascule
|
||||
api("/api/engine/use", handleEngineUse) // bascule vers une version installée (ou "image")
|
||||
api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée
|
||||
api("/api/presets", handlePresets)
|
||||
api("/api/preset", handlePreset)
|
||||
api("/api/preset/save", handlePresetSave)
|
||||
@@ -178,7 +204,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/agent/toggle", handleAgentToggle)
|
||||
api("/api/agent/compact", handleCompactToggle)
|
||||
api("/api/apikey", handleAPIKey)
|
||||
api("/api/oai/public", handleOAIPublic)
|
||||
api("/api/oai/public", handlePublicAddress) // adresse publique (domaine/IP) de cette instance
|
||||
api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link)
|
||||
api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → loki link
|
||||
api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée
|
||||
@@ -192,6 +218,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
|
||||
api("/api/internet", handleInternet)
|
||||
api("/api/mcp", handleMCP)
|
||||
api("/api/mcp/catalog", handleMCPCatalog)
|
||||
api("/api/mcp/save", handleMCPSave)
|
||||
api("/api/mcp/delete", handleMCPDelete)
|
||||
api("/api/mcp/toggle", handleMCPToggle)
|
||||
@@ -220,8 +247,8 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
||||
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
||||
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
||||
api("/api/chat/files", handleChatFiles) // liste un dossier du dossier de travail (panneau Fichiers)
|
||||
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier du dossier de travail
|
||||
api("/api/chat/files", handleChatFiles) // liste un dossier de la discussion ouverte (panneau Fichiers)
|
||||
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion
|
||||
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
|
||||
|
||||
+37
-38
@@ -1,9 +1,10 @@
|
||||
// web_upload.go — dépôt de fichiers depuis le chat.
|
||||
//
|
||||
// L'utilisateur glisse un fichier dans le composeur ; il est écrit dans
|
||||
// uploads/ à l'intérieur du workspace de l'agent (chat_workspace.go), et son
|
||||
// chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut avec ses
|
||||
// outils (read, bash, edit) — on ne tente ni extraction ni interprétation ici.
|
||||
// uploads/ à l'intérieur du dossier de la DISCUSSION ouverte (chat_convfiles.go),
|
||||
// et son chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut
|
||||
// avec ses outils (read, bash, edit) — on ne tente ni extraction ni
|
||||
// interprétation ici.
|
||||
//
|
||||
// Le transport est du JSON base64, et non du multipart : c'est la seule forme
|
||||
// qui traverse le tunnel E2E d'app.ajean.link (relay_e2e.go ne dispatche que des
|
||||
@@ -43,9 +44,11 @@ const (
|
||||
downloadChunkMax = 8 << 20
|
||||
)
|
||||
|
||||
// uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent.
|
||||
// uploadsDir renvoie (en le créant) le dossier de dépôt de la discussion
|
||||
// ouverte. Un fichier joint appartient à la discussion où il a été déposé : il
|
||||
// n'apparaît pas dans les autres et part avec elle.
|
||||
func uploadsDir() (string, error) {
|
||||
dir := filepath.Join(agentWorkspace(), "uploads")
|
||||
dir := filepath.Join(convWorkspace(), uploadsSub)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -233,22 +236,7 @@ func userMessageContent(files []attachInfo, prompt string) any {
|
||||
// on lui donne un chemin absolu (voir resolveAgentPath) ; ouvrir le téléchargement
|
||||
// à ces fichiers-là ferait de /api/chat/file un « lis-moi ce fichier du serveur »
|
||||
// à usage général — l'API n'a pas forcément de clé et écoute sur 0.0.0.0.
|
||||
func workspaceRel(abs string) (string, bool) {
|
||||
root := agentWorkspace()
|
||||
// EvalSymlinks des deux côtés : sans ça, un lien qui sort du dossier passerait
|
||||
// le test de préfixe.
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = r
|
||||
}
|
||||
if a, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = a
|
||||
}
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.ToSlash(rel), true
|
||||
}
|
||||
func workspaceRel(abs string) (string, bool) { return relWithin(agentWorkspace(), abs) }
|
||||
|
||||
// e2eInnerHeader marque une requête dispatchée depuis le proxy chiffré
|
||||
// (relay_e2e.go). Cf. handleChatFile : c'est le seul moyen pour un handler de
|
||||
@@ -275,10 +263,10 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
|
||||
return
|
||||
}
|
||||
// Un chemin ABSOLU fourni par le client ne doit pas être suivi : on le traite
|
||||
// comme relatif au dossier de travail, et le contrôle ci-dessous tranche.
|
||||
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
|
||||
if _, ok := workspaceRel(abs); !ok {
|
||||
// Un chemin ABSOLU fourni par le client ne doit pas être suivi : workspaceFile
|
||||
// le traite comme relatif au dossier de la discussion et tranche lui-même.
|
||||
abs, ok := workspaceFile(rel)
|
||||
if !ok {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
|
||||
return
|
||||
}
|
||||
@@ -356,8 +344,8 @@ func handleChatImage(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
|
||||
return
|
||||
}
|
||||
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
|
||||
if _, ok := workspaceRel(abs); !ok {
|
||||
abs, ok := workspaceFile(rel)
|
||||
if !ok {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
|
||||
return
|
||||
}
|
||||
@@ -595,19 +583,30 @@ func handleChatUpload(w http.ResponseWriter, r *http.Request) {
|
||||
// cleanStaleUploadParts efface les .part laissés par un envoi que le process n'a
|
||||
// pas pu terminer (arrêt du service, crash). Appelé au démarrage : les sessions
|
||||
// vivent en mémoire, aucun de ces fichiers n'est reprenable.
|
||||
//
|
||||
// Le balayage couvre TOUTES les discussions, pas seulement l'active : un envoi
|
||||
// coupé dans une discussion qu'on ne rouvrira jamais laisserait sinon son .part
|
||||
// occuper le disque pour toujours.
|
||||
func cleanStaleUploadParts() {
|
||||
dir, err := uploadsDir()
|
||||
if err != nil {
|
||||
return
|
||||
dirs := []string{filepath.Join(agentWorkspace(), uploadsSub)} // dépôts d'avant le rangement par discussion
|
||||
convs := filepath.Join(agentWorkspace(), convFilesRoot)
|
||||
if ents, err := os.ReadDir(convs); err == nil {
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
dirs = append(dirs, filepath.Join(convs, e.Name(), uploadsSub))
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") {
|
||||
os.Remove(filepath.Join(dir, n))
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") {
|
||||
os.Remove(filepath.Join(dir, n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ func TestSafeUploadNameKeepsOrdinaryNames(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "../notes.txt",
|
||||
"data": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("bonjour")),
|
||||
@@ -101,6 +103,8 @@ func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
|
||||
// Le téléchargement est le point sensible : il lit des fichiers du serveur à
|
||||
// partir d'un chemin fourni par le client.
|
||||
func TestChatFileStaysInsideWorkspace(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
dir, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -181,6 +185,8 @@ func uploadChunk(t *testing.T, body map[string]any) (int, map[string]any) {
|
||||
// près. C'est ce découpage qui permet le gigaoctet : sans lui, tout le fichier
|
||||
// passait dans un seul corps JSON, gardé en mémoire des deux côtés.
|
||||
func TestChatUploadChunked(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
part1 := strings.Repeat("A", 5000)
|
||||
part2 := strings.Repeat("B", 3000)
|
||||
part3 := "fin"
|
||||
@@ -239,6 +245,8 @@ func TestChatUploadUnknownSessionRefused(t *testing.T) {
|
||||
// réponse en JSON, où du binaire ne survit pas. Le handler doit donc annoncer
|
||||
// qu'on est derrière le tunnel, puis savoir servir des tranches base64.
|
||||
func TestChatFileB64ForE2E(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
dir, _ := uploadsDir()
|
||||
// Des octets NON-UTF8 : c'est exactement ce que le réemballage JSON massacre.
|
||||
raw := make([]byte, 5000)
|
||||
@@ -308,6 +316,8 @@ func TestChatFileB64ForE2E(t *testing.T) {
|
||||
// jointe). Les sessions doivent donc être indépendantes : le verrou global ne
|
||||
// protège que la table, l'écriture se fait sous le verrou de la session.
|
||||
func TestChatUploadConcurrentSessions(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
const files, chunks = 4, 6
|
||||
var wg sync.WaitGroup
|
||||
paths := make([]string, files)
|
||||
|
||||
Reference in new issue
Block a user