mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
v0.2.2 : mise a jour de securite — boite noire E2E & relais aveugle
- Chiffrement E2E du chat navigateur<->agent (e2e.go) : le relais ne voit que de l'opaque, ancre par empreinte X25519 confirmee au `jean link`. - `jean link` refuse desormais tout chat en clair via le tunnel (403) ; seul /api/e2e/chat porte du contenu. Endpoint OpenAI clair desactive par defaut (JEAN_LINK_ALLOW_OAI=1 pour le reactiver). - Le portail web n'est plus servi par le relais : code livre par une origine hors de son controle, le relais devient un pur tube aveugle.
This commit is contained in:
1 parent
ca5d3f9197
commit
a8a016c1b8
11 files changed
+403
-62
No files matched your search
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -65,7 +66,8 @@ func cmdChat(args []string) error {
|
||||
var stats *StatsEvent
|
||||
// Print the assistant prefix once; reasoning is shown inline with a tag.
|
||||
fmt.Print(cyan("jean") + " > ")
|
||||
err := runChat(InjectSkills(msgs), 0.7, func(ev StreamEvent) bool {
|
||||
caps := globalCaps()
|
||||
err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
// e2e.go — chiffrement bout-en-bout du chat entre le NAVIGATEUR et CET agent, de
|
||||
// sorte que le relais (ajean.link) ne voie que de l'opaque : « boîte noire ».
|
||||
//
|
||||
// Modèle : cet agent a une paire X25519 long-terme (clé privée locale, jamais
|
||||
// transmise). Sa clé publique est publiée au relais et son EMPREINTE est affichée
|
||||
// au `jean link` — l'utilisateur la confirme dans le portail, ce qui défait tout
|
||||
// MITM du relais. Le navigateur dérive une racine R de son mot de passe (exportKey
|
||||
// OPAQUE), la SCELLE vers la clé publique de l'agent (le relais ne peut pas
|
||||
// l'ouvrir), puis chiffre/déchiffre le chat avec une clé dérivée de R.
|
||||
//
|
||||
// Aucune dépendance externe : crypto/ecdh (X25519) + AES-GCM de la stdlib.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func e2eKeyPath() string { return filepath.Join(JeanHome(), ".e2e_key") }
|
||||
|
||||
var (
|
||||
e2eOnce sync.Once
|
||||
e2eKey *ecdh.PrivateKey
|
||||
e2eErr error
|
||||
// rCache : racines R déjà descellées, indexées par kid (évite de resceller/ouvrir
|
||||
// à chaque requête). Protégé par mu.
|
||||
e2eMu sync.Mutex
|
||||
e2eRoots = map[string][]byte{}
|
||||
)
|
||||
|
||||
// e2ePrivateKey charge (ou crée puis persiste) la clé privée X25519 de l'agent.
|
||||
func e2ePrivateKey() (*ecdh.PrivateKey, error) {
|
||||
e2eOnce.Do(func() {
|
||||
if b, err := os.ReadFile(e2eKeyPath()); err == nil {
|
||||
raw, derr := hex.DecodeString(strings.TrimSpace(string(b)))
|
||||
if derr == nil {
|
||||
e2eKey, e2eErr = ecdh.X25519().NewPrivateKey(raw)
|
||||
return
|
||||
}
|
||||
}
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
e2eErr = err
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(JeanHome(), 0o755)
|
||||
if err := os.WriteFile(e2eKeyPath(), []byte(hex.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil {
|
||||
e2eErr = err
|
||||
return
|
||||
}
|
||||
e2eKey = k
|
||||
})
|
||||
return e2eKey, e2eErr
|
||||
}
|
||||
|
||||
// e2ePubHex retourne la clé publique X25519 de l'agent (hex), pour publication au relais.
|
||||
func e2ePubHex() string {
|
||||
k, err := e2ePrivateKey()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// e2eFingerprint retourne une empreinte lisible de la clé publique (à comparer
|
||||
// dans le portail). Format : 8 groupes hex de 4 = SHA-256(pub) tronqué.
|
||||
func e2eFingerprint() string {
|
||||
k, err := e2ePrivateKey()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256(k.PublicKey().Bytes())
|
||||
s := hex.EncodeToString(h[:8]) // 16 hex
|
||||
var parts []string
|
||||
for i := 0; i < len(s); i += 4 {
|
||||
parts = append(parts, strings.ToUpper(s[i:i+4]))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
// e2eOpenSeal ouvre une boîte scellée (ephPub32 || nonce12 || ct) chiffrée vers
|
||||
// la clé publique de l'agent, et retourne le secret en clair (la racine R).
|
||||
func e2eOpenSeal(blob []byte) ([]byte, error) {
|
||||
if len(blob) < 32+12+16 {
|
||||
return nil, fmt.Errorf("sceau trop court")
|
||||
}
|
||||
priv, err := e2ePrivateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ephPubBytes := blob[:32]
|
||||
nonce := blob[32:44]
|
||||
ct := blob[44:]
|
||||
ephPub, err := ecdh.X25519().NewPublicKey(ephPubBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := priv.ECDH(ephPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := sealKey(shared, ephPubBytes, priv.PublicKey().Bytes())
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Open(nil, nonce, ct, nil)
|
||||
}
|
||||
|
||||
// sealKey dérive la clé AES de la boîte scellée (liée aux deux clés publiques).
|
||||
func sealKey(shared, ephPub, agentPub []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(shared)
|
||||
h.Write(ephPub)
|
||||
h.Write(agentPub)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// chatKey dérive la clé AES-GCM du chat depuis la racine R.
|
||||
func chatKey(r []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(r)
|
||||
h.Write([]byte("ajean-chat-v1"))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// kidOf identifie une racine R (8 premiers octets de SHA-256(R), hex).
|
||||
func kidOf(r []byte) string {
|
||||
h := sha256.Sum256(r)
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
blk, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(blk)
|
||||
}
|
||||
|
||||
// e2eResolveRoot retourne la racine R pour ce kid, en la descellant si besoin et
|
||||
// en la mémorisant. sealedR peut être vide si R est déjà connue.
|
||||
func e2eResolveRoot(kid string, sealed []byte) ([]byte, error) {
|
||||
e2eMu.Lock()
|
||||
if r, ok := e2eRoots[kid]; ok {
|
||||
e2eMu.Unlock()
|
||||
return r, nil
|
||||
}
|
||||
e2eMu.Unlock()
|
||||
if len(sealed) == 0 {
|
||||
return nil, fmt.Errorf("racine inconnue et aucun sceau fourni")
|
||||
}
|
||||
r, err := e2eOpenSeal(sealed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kidOf(r) != kid {
|
||||
return nil, fmt.Errorf("kid ne correspond pas au sceau")
|
||||
}
|
||||
e2eMu.Lock()
|
||||
e2eRoots[kid] = r
|
||||
e2eMu.Unlock()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// handleE2EChat : chat chiffré de bout en bout. Reçoit une enveloppe scellée,
|
||||
// déchiffre la requête avec la clé dérivée de R, exécute le chat, et chiffre
|
||||
// CHAQUE événement SSE. Le relais ne voit que de l'opaque.
|
||||
func handleE2EChat(w http.ResponseWriter, r *http.Request) {
|
||||
var env struct{ Kid, SealedR, Iv, Ct string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&env); err != nil {
|
||||
http.Error(w, "e2e: enveloppe invalide", 400)
|
||||
return
|
||||
}
|
||||
var sealed []byte
|
||||
if env.SealedR != "" {
|
||||
sealed, _ = base64.StdEncoding.DecodeString(env.SealedR)
|
||||
}
|
||||
root, err := e2eResolveRoot(env.Kid, sealed)
|
||||
if err != nil {
|
||||
http.Error(w, "e2e: "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
gcm, err := newGCM(chatKey(root))
|
||||
if err != nil {
|
||||
http.Error(w, "e2e: clé", 500)
|
||||
return
|
||||
}
|
||||
iv, _ := base64.StdEncoding.DecodeString(env.Iv)
|
||||
ct, _ := base64.StdEncoding.DecodeString(env.Ct)
|
||||
plain, err := gcm.Open(nil, iv, ct, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "e2e: déchiffrement", 400)
|
||||
return
|
||||
}
|
||||
var body chatReq
|
||||
if err := json.Unmarshal(plain, &body); err != nil {
|
||||
http.Error(w, "e2e: requête", 400)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
emit := func(obj map[string]any) bool {
|
||||
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return false
|
||||
}
|
||||
sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...)
|
||||
if _, err := w.Write([]byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n")); err != nil {
|
||||
return false
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
runChatStream(r.Context(), body, emit)
|
||||
}
|
||||
|
||||
// e2eSeal (côté agent : utilisé uniquement pour les tests) scelle un secret vers
|
||||
// une clé publique X25519. La version navigateur est en WASM (wasmclient).
|
||||
func e2eSeal(agentPub, secret []byte) ([]byte, error) {
|
||||
pub, err := ecdh.X25519().NewPublicKey(agentPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := eph.ECDH(pub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := sealKey(shared, eph.PublicKey().Bytes(), agentPub)
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct := gcm.Seal(nil, nonce, secret, nil)
|
||||
out := append([]byte{}, eph.PublicKey().Bytes()...)
|
||||
out = append(out, nonce...)
|
||||
out = append(out, ct...)
|
||||
return out, nil
|
||||
}
|
||||
@@ -111,6 +111,13 @@ func cmdLink(args []string) error {
|
||||
fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL())
|
||||
fmt.Printf(" (UI Jean + endpoint OpenAI servis dans le tunnel — pas besoin de 'jean web')\n")
|
||||
|
||||
// Empreinte E2E : à confirmer UNE FOIS dans le portail. Garantit que le chat
|
||||
// est chiffré vers CET agent et pas vers un relais qui se ferait passer pour lui.
|
||||
if fp := e2eFingerprint(); fp != "" {
|
||||
fmt.Printf("\n%s empreinte E2E de cette machine :\n %s\n", green("[e2e]"), bold(fp))
|
||||
fmt.Printf(" Confirme-la dans le portail (Mon compte → serveur) pour activer la boîte noire.\n\n")
|
||||
}
|
||||
|
||||
// On construit le handler une seule fois ; il est servi à travers chaque tunnel.
|
||||
handler := newLinkHandler()
|
||||
|
||||
@@ -153,9 +160,23 @@ func newLinkHandler() http.Handler {
|
||||
lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway)
|
||||
}
|
||||
// Boîte noire : tout ce qui est servi ici transite par le relais. On REFUSE
|
||||
// donc tout chemin qui ferait passer des prompts/réponses EN CLAIR. Seul le
|
||||
// chat chiffré de bout en bout (/api/e2e/chat) est autorisé à porter du contenu.
|
||||
oaiAllowed := os.Getenv("JEAN_LINK_ALLOW_OAI") == "1"
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
if p == "/api/chat" {
|
||||
http.Error(w, "chat en clair refusé via le relais : utilise le chat chiffré (boîte noire)", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {
|
||||
// L'endpoint OpenAI (OpenCode/Hermes) ne peut pas être chiffré navigateur :
|
||||
// il transiterait en clair par le relais. Désactivé par défaut.
|
||||
if !oaiAllowed {
|
||||
http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; JEAN_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
lp.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -189,6 +210,9 @@ func runLinkSession(token string, handler http.Handler) error {
|
||||
host, _ := os.Hostname()
|
||||
dialURL := relayURL()
|
||||
q := url.Values{"m": {machineID()}, "h": {host}}
|
||||
if pk := e2ePubHex(); pk != "" {
|
||||
q.Set("pk", pk) // clé publique E2E → publiée au relais pour le scellement navigateur
|
||||
}
|
||||
if strings.Contains(dialURL, "?") {
|
||||
dialURL += "&" + q.Encode()
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -81,17 +82,37 @@ func runShellTool() Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// InjectSkills prepends context system messages to msgs: the machine briefing
|
||||
// (when machine access is enabled) and the lightweight skills directory (when
|
||||
// skills are enabled). Merges with an existing system message if present.
|
||||
func InjectSkills(msgs []Message) []Message {
|
||||
// Caps are the per-request capabilities (tool access) for a chat turn. They let
|
||||
// a caller (e.g. an ajean.link agent with its own tools/skills toggles) scope
|
||||
// what the model can do for this conversation, instead of always inheriting the
|
||||
// machine's global config. Use globalCaps() to fall back to the global config.
|
||||
type Caps struct {
|
||||
Tools bool
|
||||
Skills bool
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
// specify its own capabilities.
|
||||
func globalCaps() Caps {
|
||||
return Caps{Tools: toolsEnabled(), Skills: skillsEnabled()}
|
||||
}
|
||||
|
||||
// InjectSkills prepends context system messages to msgs: the decisive-agent
|
||||
// preamble + machine briefing (when tools are enabled) and the lightweight
|
||||
// skills directory (when skills are enabled). Merges with an existing system
|
||||
// message if present.
|
||||
func InjectSkills(msgs []Message, caps Caps) []Message {
|
||||
var parts []string
|
||||
// Always-on decisive-agent preamble (anti-loop). See baseSystemPrompt.
|
||||
parts = append(parts, baseSystemPrompt())
|
||||
if mp := machineSystemPrompt(); mp != "" {
|
||||
// Decisive-agent preamble (anti-loop) — only when the model actually has
|
||||
// tools, otherwise it nudges a plain chat model to "call tools" it doesn't
|
||||
// have, which leaks malformed tool-call text into the answer.
|
||||
if bp := baseSystemPrompt(caps); bp != "" {
|
||||
parts = append(parts, bp)
|
||||
}
|
||||
if mp := machineSystemPrompt(caps); mp != "" {
|
||||
parts = append(parts, mp)
|
||||
}
|
||||
if sp := skillsSystemPrompt(); sp != "" {
|
||||
if sp := skillsSystemPrompt(caps); sp != "" {
|
||||
parts = append(parts, sp)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
@@ -107,12 +128,12 @@ func InjectSkills(msgs []Message) []Message {
|
||||
}
|
||||
|
||||
// EnabledTools returns the tools to advertise on the next inference call.
|
||||
func EnabledTools() []Tool {
|
||||
func EnabledTools(caps Caps) []Tool {
|
||||
tools := []Tool{}
|
||||
if skillsEnabled() {
|
||||
if caps.Skills {
|
||||
tools = append(tools, skillTool())
|
||||
}
|
||||
if toolsEnabled() {
|
||||
if caps.Tools {
|
||||
tools = append(tools, runShellTool())
|
||||
}
|
||||
return tools
|
||||
@@ -223,8 +244,8 @@ type streamChunk struct {
|
||||
// and call /v1/chat/completions again — up to 8 iterations as a safety cap.
|
||||
const thinkClose = "</think>"
|
||||
|
||||
func runChat(messages []Message, temperature float64, cb ChatCallback) error {
|
||||
tools := EnabledTools()
|
||||
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) error {
|
||||
tools := EnabledTools(caps)
|
||||
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
|
||||
// in streaming mode: the model's <think> block (opened by the chat template)
|
||||
// arrives inline in `content`, terminated by a literal </think>. When
|
||||
@@ -257,7 +278,7 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error {
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.2.1"
|
||||
const Version = "0.2.2"
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
@@ -61,12 +61,13 @@ func cmdServe(args []string) error {
|
||||
llmArgs = append(llmArgs, "-ctv", vtv)
|
||||
}
|
||||
if r := cfg["REASONING"]; r != "" {
|
||||
// budget plafonné par défaut (2048) : une réflexion qui part en boucle est
|
||||
// coupée net au lieu de tourner à l'infini — c'est le comportement de pi.
|
||||
// NE PAS forcer 0 : sur llama.cpp vanilla, 0 = "immediate end" → coupe tout
|
||||
// le raisonnement (le fork ik_llama.cpp l'ignore). Mettre -1 pour illimité.
|
||||
// Configurable via REASONING_BUDGET.
|
||||
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "2048"))
|
||||
// budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au
|
||||
// bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la
|
||||
// réflexion atteignait le plafond et il ne restait plus de marge pour le
|
||||
// contenu). L'anti-boucle côté llm.go reste le garde-fou. NE PAS forcer 0 :
|
||||
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
|
||||
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
|
||||
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
|
||||
}
|
||||
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
|
||||
@@ -156,8 +156,8 @@ func DeleteSkill(name string) error {
|
||||
|
||||
// skillsSystemPrompt returns the lightweight skills directory message to
|
||||
// prepend to the conversation when skills are enabled.
|
||||
func skillsSystemPrompt() string {
|
||||
if !skillsEnabled() {
|
||||
func skillsSystemPrompt(caps Caps) string {
|
||||
if !caps.Skills {
|
||||
return ""
|
||||
}
|
||||
list := ListSkills()
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,7 +24,7 @@ func cmdTest(args []string) error {
|
||||
t0 := time.Now()
|
||||
var firstTok time.Time
|
||||
tokens := 0
|
||||
err := runChat(msgs, 0, func(ev StreamEvent) bool {
|
||||
err := runChat(context.Background(), msgs, 0, Caps{}, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
if firstTok.IsZero() {
|
||||
firstTok = time.Now()
|
||||
|
||||
@@ -43,7 +43,13 @@ func setToolsEnabled(on bool) error {
|
||||
// ("Wait, let me check… Wait, I'll just run it…") and commits to an action.
|
||||
// The per-tool "Outil disponible" sections live in machine/skills prompts so
|
||||
// they only appear when the matching feature is on.
|
||||
func baseSystemPrompt() string {
|
||||
func baseSystemPrompt(caps Caps) string {
|
||||
// No tool access → no agentic preamble. A plain chat model told to "call
|
||||
// tools immediately" hallucinates textual tool calls (e.g. default_api:bash)
|
||||
// that leak into the answer. Let the user's own system prompt stand alone.
|
||||
if !caps.Tools && !caps.Skills {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Tu es un assistant expert opérant dans jean, un agent qui agit directement sur cette machine. Tu aides l'utilisateur en inspectant le système, en exécutant des commandes et en gérant des skills réutilisables.\n\n")
|
||||
b.WriteString("Méthode :\n")
|
||||
@@ -65,8 +71,8 @@ func baseSystemPrompt() string {
|
||||
// running on, so that when machine access is enabled it knows *which* machine
|
||||
// run_shell acts upon (and doesn't claim it has no access to "your PC").
|
||||
// Returns "" when machine access is off.
|
||||
func machineSystemPrompt() string {
|
||||
if !toolsEnabled() {
|
||||
func machineSystemPrompt(caps Caps) string {
|
||||
if !caps.Tools {
|
||||
return ""
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
|
||||
+6
-5
@@ -70,6 +70,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
#chat{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:14px}
|
||||
.msg{max-width:80%;padding:10px 14px;border-radius:10px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
|
||||
.msg.assistant,.msg.reasoning{white-space:normal}
|
||||
.msg .body{overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.user{align-self:flex-end;background:#1f6feb;color:#fff}
|
||||
.msg.assistant{align-self:flex-start;background:var(--panel);border:1px solid var(--border)}
|
||||
.msg .body>*{margin:1em 0}
|
||||
@@ -80,13 +81,13 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.msg .body ul,.msg .body ol{padding-left:1.4em}
|
||||
.msg .body li{margin:.15em 0}
|
||||
.msg .body li>p{margin:0}
|
||||
.msg.assistant .body code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.9em}
|
||||
.msg.assistant .body pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;margin:.5em 0}
|
||||
.msg.assistant .body code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
|
||||
.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:5px;opacity:.5;transition:opacity .15s,color .15s}
|
||||
.msg.assistant .body pre:hover .copybtn{opacity:1}
|
||||
.msg.assistant .body pre:hover .copybtn,.msg.reasoning .body pre:hover .copybtn{opacity:1}
|
||||
.copybtn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.copybtn.done{color:var(--ok);border-color:var(--ok);opacity:1}
|
||||
.msg.assistant .body pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4}
|
||||
.msg.assistant .body pre code,.msg.reasoning .body pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4}
|
||||
.msg.assistant .body blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)}
|
||||
.msg.assistant .body a{color:var(--accent);text-decoration:none}
|
||||
.msg.assistant .body a:hover{text-decoration:underline}
|
||||
@@ -95,7 +96,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.msg.assistant .body hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
|
||||
#scrollbtn{position:absolute;bottom:80px;right:20px;background:var(--panel);border:1px solid var(--border);color:var(--accent);width:36px;height:36px;border-radius:50%;cursor:pointer;display:none;font-size:16px;z-index:5}
|
||||
#scrollbtn.show{display:block}
|
||||
.msg.reasoning{align-self:flex-start;background:transparent;border:1px dashed var(--border);color:var(--mag);font-size:12px;max-width:90%}
|
||||
.msg.reasoning{align-self:flex-start;background:transparent;border:1px dashed var(--border);color:var(--mag);font-size:12px;max-width:90%;overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.tool{align-self:flex-start;background:var(--panel);border:1px solid var(--border);max-width:100%;white-space:normal;padding:8px 12px}
|
||||
.msg.tool .tool-head{font-size:12px;color:var(--accent);font-weight:600;margin-bottom:6px}
|
||||
.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -95,6 +96,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/bench", handleBench)
|
||||
api("/api/bench/last", handleBenchLast)
|
||||
api("/api/chat", handleChat)
|
||||
api("/api/e2e/chat", handleE2EChat) // chat chiffré E2E (boîte noire via le relais)
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -566,18 +568,57 @@ func handleBenchLast(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At})
|
||||
}
|
||||
|
||||
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
// chatReq est le corps d'une requête de chat (commun au chat clair et au chat E2E).
|
||||
type chatReq struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
// Optional per-request capability overrides (used by ajean.link agents,
|
||||
// which carry their own tools/skills toggles). nil = inherit the
|
||||
// machine's global config.
|
||||
Tools *bool `json:"tools"`
|
||||
Skills *bool `json:"skills"`
|
||||
}
|
||||
|
||||
// runChatStream exécute le chat et pousse chaque événement (delta) via emit, qui
|
||||
// renvoie false pour interrompre. Partagé par handleChat et handleE2EChat.
|
||||
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
|
||||
if body.Temperature == 0 {
|
||||
body.Temperature = 0.7
|
||||
}
|
||||
caps := globalCaps()
|
||||
if body.Tools != nil {
|
||||
caps.Tools = *body.Tools
|
||||
}
|
||||
if body.Skills != nil {
|
||||
caps.Skills = *body.Skills
|
||||
}
|
||||
msgs := InjectSkills(body.Messages, caps)
|
||||
_ = runChat(ctx, msgs, body.Temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Err != nil {
|
||||
return emit(map[string]any{"error": ev.Err.Error()})
|
||||
}
|
||||
if ev.ToolUsed != nil {
|
||||
return emit(map[string]any{"tool_used": map[string]any{"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label, "result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing}})
|
||||
}
|
||||
if ev.Stats != nil {
|
||||
return emit(map[string]any{"stats": ev.Stats})
|
||||
}
|
||||
if ev.Reasoning != "" {
|
||||
return emit(map[string]any{"reasoning_content": ev.Reasoning})
|
||||
}
|
||||
if ev.Content != "" {
|
||||
return emit(map[string]any{"content": ev.Content})
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
var body chatReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if body.Temperature == 0 {
|
||||
body.Temperature = 0.7
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
@@ -592,27 +633,6 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
msgs := InjectSkills(body.Messages)
|
||||
_ = runChat(msgs, body.Temperature, func(ev StreamEvent) bool {
|
||||
if ev.Err != nil {
|
||||
emit(map[string]any{"error": ev.Err.Error()})
|
||||
return true
|
||||
}
|
||||
if ev.ToolUsed != nil {
|
||||
emit(map[string]any{"tool_used": map[string]any{"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label, "result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing}})
|
||||
return true
|
||||
}
|
||||
if ev.Stats != nil {
|
||||
emit(map[string]any{"stats": ev.Stats})
|
||||
return true
|
||||
}
|
||||
if ev.Reasoning != "" {
|
||||
return emit(map[string]any{"reasoning_content": ev.Reasoning})
|
||||
}
|
||||
if ev.Content != "" {
|
||||
return emit(map[string]any{"content": ev.Content})
|
||||
}
|
||||
return true
|
||||
})
|
||||
runChatStream(r.Context(), body, emit)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user