Merge branch 'claude/discussion-file-linking-nzgd0d'

This commit is contained in:
Claude committed 2026-08-16 19:45:54 +00:00
commit af83257c76
15 files changed
+858 -241

No files matched your search

+22 -1
View File
@@ -156,7 +156,8 @@ Héritées d'AJEAN :
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **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 :
@@ -195,6 +196,25 @@ Ajoutées par ce fork :
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
@@ -227,6 +247,7 @@ Retirées par ce fork :
| 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.
+34 -3
View File
@@ -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
+1 -3
View File
@@ -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
+67 -14
View File
@@ -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
+251
View File
@@ -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é")
}
}
+12
View File
@@ -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)
}
+52 -96
View File
@@ -1835,6 +1835,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
.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)}
</style></head><body>
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -2088,35 +2094,30 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</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 &lt;clé&gt;</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 &lt;clé&gt;</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://&lt;machine&gt;.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>
@@ -3460,27 +3461,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
@@ -3612,50 +3625,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;
@@ -3664,24 +3638,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;
@@ -3725,7 +3681,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); }
+12 -17
View File
@@ -283,35 +283,30 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</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 &lt;clé&gt;</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 &lt;clé&gt;</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://&lt;machine&gt;.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>
+34 -79
View File
@@ -231,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
@@ -383,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;
@@ -435,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;
@@ -496,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); }
+6
View File
@@ -1804,3 +1804,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
.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)}
+34 -26
View File
@@ -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 {
+57
View File
@@ -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
+142
View File
@@ -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)
}
+116
View File
@@ -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)
}
}
+18 -2
View File
@@ -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)
}
@@ -140,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)
@@ -181,7 +197,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