mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6911af27 | ||
|
|
965a4d6247 | ||
|
|
99c96a6c33 | ||
|
|
6a45f19831 | ||
|
|
4c2cf8b4e6 | ||
|
|
68dd65cd5a | ||
|
|
01c11be4ae |
No files matched your search
@@ -52,6 +52,21 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
Le **rendu des formules LaTeX** dans les réponses (`ui/src/js/00-math.js`,
|
||||
`web_katex.go`) est repris d'AJEAN (#123) : $…$, $$…$$, \\(…\\) et \\[…\\] rendus
|
||||
par KaTeX, chargé à la première formule seulement, sans toucher au code ni aux
|
||||
montants en $. Loki y ajoute deux garde-fous de détection (« 5 $, » et un $
|
||||
voisin d'un code en ligne restent du texte) et sert la route `/katex/` aussi
|
||||
depuis la façade Rust (`rust/loki-rs/src/ui.rs`).
|
||||
|
||||
## KaTeX
|
||||
|
||||
**[KaTeX](https://katex.org/)** 0.16.11 (licence MIT, © Khan Academy et
|
||||
contributeurs) est embarqué tel que publié — script, feuille de style et
|
||||
polices woff2 — sous `internal/loki/ui/katex/`, avec sa licence
|
||||
(`internal/loki/ui/katex/LICENSE`). Il est servi par Loki sous `/katex/`, sans
|
||||
appel à un CDN.
|
||||
|
||||
## Moteur Strata
|
||||
|
||||
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
|
||||
|
||||
@@ -242,6 +242,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
|
||||
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
|
||||
}
|
||||
|
||||
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
|
||||
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
|
||||
// chaque étape suivante relançait un résumé complet voué au même échec : plus
|
||||
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
|
||||
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
|
||||
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
|
||||
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
|
||||
// encore vaut mieux que déborder.
|
||||
func compactRetryDue(retryAt, used int, msgs []Message) bool {
|
||||
if retryAt <= 0 {
|
||||
return true
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
return est >= retryAt || est*10 >= ctxWindow()*9
|
||||
}
|
||||
|
||||
// compactNextRetry : seuil du prochain essai après une compaction en cours de
|
||||
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
|
||||
// de la fenêtre.
|
||||
func compactNextRetry(changed bool, used int, msgs []Message) int {
|
||||
if changed {
|
||||
return 0
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
next := est + ctxWindow()/20
|
||||
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
|
||||
return next
|
||||
}
|
||||
|
||||
func compactUsedOrEstimate(used int, msgs []Message) int {
|
||||
if used > 0 {
|
||||
return used
|
||||
}
|
||||
return estimateTokens(msgs)
|
||||
}
|
||||
|
||||
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
|
||||
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
|
||||
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
|
||||
@@ -437,18 +473,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
||||
var index []recallEntry
|
||||
if caps.Agent && compactEnabled() {
|
||||
names := torsoToolNames(torso)
|
||||
var blocks []recallArchiveInput
|
||||
var at []int // position dans le torse de chaque bloc
|
||||
for i, m := range torso {
|
||||
if !recallEligible(m) {
|
||||
continue
|
||||
}
|
||||
content := msgText(m)
|
||||
label := recallLabel(m.Role, names[m.ToolCallID], content)
|
||||
id, aerr := archiveRecallBlock(label, m.Role, content)
|
||||
if aerr != nil {
|
||||
continue
|
||||
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
|
||||
at = append(at, i)
|
||||
}
|
||||
// Une seule transaction pour tous les blocs (AJEAN #130).
|
||||
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
|
||||
for k, e := range entries {
|
||||
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
|
||||
index = append(index, e)
|
||||
}
|
||||
archived[i] = &recallEntry{id: id, label: label}
|
||||
index = append(index, recallEntry{id: id, label: label})
|
||||
} else if len(blocks) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +570,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
||||
} else {
|
||||
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
}
|
||||
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
|
||||
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
|
||||
// 68659e73).
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
|
||||
} else if summaryLooksEmpty(summary) {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
|
||||
}
|
||||
var mid []Message
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
|
||||
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
|
||||
// fenêtre — sauf à 90 % de la fenêtre ou plus.
|
||||
func TestCompactRetryBackoff(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !compactRetryDue(0, 70000, nil) {
|
||||
t.Fatal("sans échec précédent, l'essai doit être permis")
|
||||
}
|
||||
next := compactNextRetry(false, 70000, nil)
|
||||
if next != 75000 {
|
||||
t.Fatalf("prochain essai à %d, attendu 75000", next)
|
||||
}
|
||||
if compactRetryDue(next, 72000, nil) {
|
||||
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
|
||||
}
|
||||
if !compactRetryDue(next, 75000, nil) {
|
||||
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
|
||||
}
|
||||
if !compactRetryDue(200000, 90000, nil) {
|
||||
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
|
||||
}
|
||||
if compactNextRetry(true, 70000, nil) != 0 {
|
||||
t.Fatal("une compaction réussie doit lever le garde-fou")
|
||||
}
|
||||
}
|
||||
@@ -78,58 +78,88 @@ func recallIDToSeq(id string) (uint64, bool) {
|
||||
return n, true
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
|
||||
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
|
||||
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
|
||||
type recallArchiveInput struct {
|
||||
Label string
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// archiveRecallBlocks enregistre tous les blocs d'une compaction dans UNE
|
||||
// transaction et renvoie leurs entrées, dans l'ordre des blocs fournis. Les ids
|
||||
// sont alloués par NextSequence dans la transaction : monotones, sans
|
||||
// collision, même si deux compactages se croisent. Une transaction par bloc
|
||||
// payait une ouverture de la base et une synchronisation disque chacune, sous
|
||||
// le verrou global de la base, en plein tour (AJEAN #130). Tout ou rien : en
|
||||
// cas d'échec (base indisponible, ex. tests sans LOKI_HOME, mémoire
|
||||
// verrouillée) aucun id n'est rendu, et l'appelant se rabat sur le compactage
|
||||
// classique (troncature sans id).
|
||||
func archiveRecallBlocks(blocks []recallArchiveInput) ([]recallEntry, error) {
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
var id string
|
||||
now := time.Now().Unix()
|
||||
var entries []recallEntry
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
entries = entries[:0]
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
for _, in := range blocks {
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: in.Label,
|
||||
Role: in.Role,
|
||||
Content: in.Content,
|
||||
Created: now,
|
||||
}
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré
|
||||
// comme le reste quand la mémoire l'est (l'encodeur rend le clair
|
||||
// inchangé si le chiffrement est inactif ; verrouillé,
|
||||
// memEncoderNow a déjà refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Put(recallKey(seq), enc); err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, recallEntry{id: blk.ID, label: blk.Label})
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: label,
|
||||
Role: role,
|
||||
Content: content,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
id = blk.ID
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
cacheBust(bkRecall)
|
||||
return id, nil
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un seul bloc et renvoie son id ("r<seq>").
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
e, err := archiveRecallBlocks([]recallArchiveInput{{Label: label, Role: role, Content: content}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e[0].id, nil
|
||||
}
|
||||
|
||||
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
|
||||
|
||||
@@ -221,3 +221,47 @@ func TestCompactSummaryUserMsg(t *testing.T) {
|
||||
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
|
||||
}
|
||||
}
|
||||
|
||||
// Les blocs d'une compaction s'écrivent en une seule transaction : ids dans
|
||||
// l'ordre des blocs, croissants, chacun relisible mot pour mot.
|
||||
func TestArchiveRecallBlocksBatch(t *testing.T) {
|
||||
testHome(t)
|
||||
if e, err := archiveRecallBlocks(nil); err != nil || e != nil {
|
||||
t.Fatalf("lot vide : %v, %v", e, err)
|
||||
}
|
||||
in := []recallArchiveInput{
|
||||
{Label: "un", Role: "tool", Content: bigBlock("PREMIER")},
|
||||
{Label: "deux", Role: "user", Content: bigBlock("SECOND")},
|
||||
{Label: "trois", Role: "assistant", Content: bigBlock("TROISIEME")},
|
||||
}
|
||||
entries, err := archiveRecallBlocks(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != len(in) {
|
||||
t.Fatalf("%d entrées pour %d blocs", len(entries), len(in))
|
||||
}
|
||||
var last uint64
|
||||
for i, e := range entries {
|
||||
seq, ok := recallIDToSeq(e.id)
|
||||
if !ok || seq <= last {
|
||||
t.Fatalf("id %q non croissant (précédent %d)", e.id, last)
|
||||
}
|
||||
last = seq
|
||||
if e.label != in[i].Label {
|
||||
t.Fatalf("entrée %d : libellé %q, attendu %q", i, e.label, in[i].Label)
|
||||
}
|
||||
blk, ok := recallGet(e.id)
|
||||
if !ok || blk.Content != in[i].Content || blk.Role != in[i].Role {
|
||||
t.Fatalf("bloc %s mal relu : %+v", e.id, blk)
|
||||
}
|
||||
}
|
||||
// Un archivage isolé suit la même séquence.
|
||||
id, err := archiveRecallBlock("quatre", "tool", bigBlock("QUATRE"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seq, _ := recallIDToSeq(id); seq != last+1 {
|
||||
t.Fatalf("id isolé %q, attendu r%d", id, last+1)
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,15 @@ func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
|
||||
}
|
||||
testHome(t)
|
||||
start := time.Now()
|
||||
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
|
||||
if d := time.Since(start); d > 10*time.Second {
|
||||
// Délai de 10 s, bien au-delà de WaitDelay (2 s) : c'est le détachement
|
||||
// qui rend la main, pas le délai de la commande.
|
||||
out := runShell(context.Background(), "sleep 30 & echo lance", 10)
|
||||
if d := time.Since(start); d > 8*time.Second {
|
||||
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
|
||||
}
|
||||
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
|
||||
// La commande a fini (exit 0) : sa sortie est rendue, pas effacée par
|
||||
// « WaitDelay expired » (AJEAN 7403a8a0).
|
||||
if !strings.Contains(out, "exit: 0") || !strings.Contains(out, "lance") || !strings.Contains(out, "arrière-plan") {
|
||||
t.Errorf("sortie inattendue : %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,14 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// issue connue était de redémarrer loki-ui.
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
err := cmd.Run()
|
||||
// ErrWaitDelay : la commande s'est terminée normalement, mais un process
|
||||
// laissé en arrière-plan tenait encore les tubes. Ce qu'elle a écrit est
|
||||
// déjà capturé : le rendre, au lieu de « [erreur: exec: WaitDelay expired
|
||||
// before I/O complete] » qui l'effaçait (repris d'AJEAN 7403a8a0).
|
||||
detached := errors.Is(err, exec.ErrWaitDelay)
|
||||
if detached {
|
||||
err = nil
|
||||
}
|
||||
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
|
||||
// demandait un tail.
|
||||
clean := func(s string) string { return tailOutput(stripANSI(s)) }
|
||||
@@ -337,6 +345,9 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
if errOut != "" {
|
||||
parts = append(parts, "stderr:\n"+errOut)
|
||||
}
|
||||
if detached {
|
||||
parts = append(parts, "(un process lancé en arrière-plan tourne toujours ; sa sortie n'est plus suivie — pour un serveur, utilise bash_bg)")
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
|
||||
+75
-19
@@ -16,6 +16,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
@@ -40,39 +41,94 @@ var defaultRetryPatterns = []string{
|
||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||||
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
|
||||
// choisi son rôle (CLI, serveur…).
|
||||
//
|
||||
// Relus quand la surcharge change (OpenFox c52c5bf7) : un sync.Once les
|
||||
// figeait jusqu'au redémarrage du process. retryPatterns tourne pendant le flux,
|
||||
// à chaque morceau : la base n'est rouverte que si une écriture de ce process a
|
||||
// touché le bucket d'état (retryStateBust, via cacheBust) ou, au plus une fois
|
||||
// par seconde, si date ou taille du fichier trahissent l'écriture d'un autre
|
||||
// process (`loki config set` pendant que le service tourne).
|
||||
var (
|
||||
retryOnce sync.Once
|
||||
retryRes []*regexp.Regexp
|
||||
retryMu sync.Mutex
|
||||
retryRes []*regexp.Regexp
|
||||
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
|
||||
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
|
||||
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
|
||||
retryCustom bool
|
||||
retryCustom bool
|
||||
retryLoaded bool
|
||||
retryStale bool // écriture locale du bucket d'état depuis la lecture
|
||||
retryRaw string
|
||||
retryPath string // la base lue (LOKI_HOME change d'un test à l'autre)
|
||||
retryChecked time.Time
|
||||
retryMtime time.Time
|
||||
retrySize int64
|
||||
)
|
||||
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
|
||||
return retryRes
|
||||
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
|
||||
func retryStateBust() {
|
||||
retryMu.Lock()
|
||||
retryStale = true
|
||||
retryMu.Unlock()
|
||||
}
|
||||
|
||||
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
|
||||
pats := defaultRetryPatterns
|
||||
custom := false
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
|
||||
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
|
||||
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
|
||||
if raw := getStr(bkState, "retry_patterns"); raw != "" {
|
||||
var over []string
|
||||
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
|
||||
pats, custom = over, true
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
res, _ := retryState()
|
||||
return res
|
||||
}
|
||||
|
||||
// retryState renvoie les motifs compilés et si ce sont ceux de la surcharge.
|
||||
func retryState() ([]*regexp.Regexp, bool) {
|
||||
retryMu.Lock()
|
||||
defer retryMu.Unlock()
|
||||
reload := !retryLoaded || retryStale || retryPath != dbPath()
|
||||
if !reload && time.Since(retryChecked) >= time.Second {
|
||||
retryChecked = time.Now()
|
||||
if mtime, size := dbStamp(); size != retrySize || !mtime.Equal(retryMtime) {
|
||||
reload = true
|
||||
}
|
||||
}
|
||||
if reload {
|
||||
raw := getStr(bkState, "retry_patterns")
|
||||
retryMtime, retrySize = dbStamp()
|
||||
retryChecked, retryStale, retryPath = time.Now(), false, dbPath()
|
||||
if !retryLoaded || raw != retryRaw {
|
||||
retryRes, retryCustom = compileRetryPatterns(raw)
|
||||
retryRaw, retryLoaded = raw, true
|
||||
}
|
||||
}
|
||||
return retryRes, retryCustom
|
||||
}
|
||||
|
||||
// compileRetryPatterns compile la surcharge raw (tableau JSON de regex), ou
|
||||
// les motifs embarqués quand elle est absente, illisible, ou sans un seul motif
|
||||
// utilisable. Un motif vide est écarté : "(?mi)" correspond à TOUT, et chaque
|
||||
// tour était relancé une fois (OpenFox 8e9b4869).
|
||||
func compileRetryPatterns(raw string) ([]*regexp.Regexp, bool) {
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns`. Pas d'UI dédiée —
|
||||
// c'est un réglage d'expert, posé via `loki config` ou l'API ; l'embarqué
|
||||
// couvre les cas connus.
|
||||
if raw != "" {
|
||||
var over []string
|
||||
if json.Unmarshal([]byte(raw), &over) == nil {
|
||||
if out := compileRetryList(over); len(out) > 0 {
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return compileRetryList(defaultRetryPatterns), false
|
||||
}
|
||||
|
||||
func compileRetryList(pats []string) []*regexp.Regexp {
|
||||
var out []*regexp.Regexp
|
||||
for _, p := range pats {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
||||
out = append(out, re)
|
||||
}
|
||||
}
|
||||
return out, custom
|
||||
return out
|
||||
}
|
||||
|
||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||||
@@ -100,9 +156,9 @@ func textualToolCallFrom(content string, scanned int) bool {
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
res := retryPatterns()
|
||||
res, custom := retryState()
|
||||
start := 0
|
||||
if !retryCustom {
|
||||
if !custom {
|
||||
start = retryScanStart(content, scanned)
|
||||
}
|
||||
w := content[start:]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Un motif vide dans la surcharge correspondait à tout : chaque tour était
|
||||
// relancé une fois. Il est écarté ; une surcharge sans motif utilisable retombe
|
||||
// sur les motifs embarqués.
|
||||
func TestCompileRetryPatternsSkipsEmpty(t *testing.T) {
|
||||
res, custom := compileRetryPatterns(`["", " ", "<oups>"]`)
|
||||
if !custom || len(res) != 1 {
|
||||
t.Fatalf("custom=%v, %d motifs ; attendu la surcharge avec un seul motif", custom, len(res))
|
||||
}
|
||||
if res[0].MatchString("une réponse tout à fait normale") {
|
||||
t.Fatal("un texte ordinaire déclenche une relance")
|
||||
}
|
||||
res, custom = compileRetryPatterns(`["", "([invalide"]`)
|
||||
if custom || len(res) != len(defaultRetryPatterns) {
|
||||
t.Fatalf("custom=%v, %d motifs ; attendu les motifs embarqués", custom, len(res))
|
||||
}
|
||||
if _, custom = compileRetryPatterns(`pas du json`); custom {
|
||||
t.Fatal("surcharge illisible prise en compte")
|
||||
}
|
||||
}
|
||||
|
||||
// Un changement de retry_patterns agit sans redémarrer le process.
|
||||
func TestRetryPatternsReloadOnChange(t *testing.T) {
|
||||
testHome(t)
|
||||
retryStateBust()
|
||||
if _, custom := retryState(); custom {
|
||||
t.Fatal("surcharge active sans clé")
|
||||
}
|
||||
if err := putStr(bkState, "retry_patterns", `["OUPS-OUTIL"]`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !textualToolCall("bon, OUPS-OUTIL, voilà") {
|
||||
t.Fatal("nouveau motif ignoré après l'écriture")
|
||||
}
|
||||
if err := putStr(bkState, "retry_patterns", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, custom := retryState(); custom {
|
||||
t.Fatal("surcharge retirée mais toujours active")
|
||||
}
|
||||
if textualToolCall("bon, OUPS-OUTIL, voilà") {
|
||||
t.Fatal("ancien motif encore appliqué")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -857,10 +858,10 @@ func TestFacadeMCP(t *testing.T) {
|
||||
if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" {
|
||||
t.Fatalf("outils : %+v", tools)
|
||||
}
|
||||
if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" {
|
||||
if got := mcpCall(context.Background(), "mcp__a__t", map[string]any{"x": 1}); got != "fait" {
|
||||
t.Fatalf("appel : %q", got)
|
||||
}
|
||||
if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
|
||||
if got := mcpCall(context.Background(), "mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
|
||||
t.Fatalf("appel sans arguments : %q (%v)", got, calls)
|
||||
}
|
||||
MCPPrewarm()
|
||||
@@ -872,7 +873,7 @@ func TestFacadeMCP(t *testing.T) {
|
||||
}
|
||||
// Façade injoignable : une erreur lisible, pas de panique.
|
||||
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
|
||||
if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
|
||||
if got := mcpCall(context.Background(), "mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
|
||||
t.Fatalf("façade injoignable : %q", got)
|
||||
}
|
||||
if tools := mcpTools(); tools != nil {
|
||||
|
||||
@@ -1373,6 +1373,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
// Contexte estimé pour la requête suivante (en-tour), comparé au compte
|
||||
// réel du moteur quand il arrive — journalisé s'il s'en écarte.
|
||||
lastEst := 0
|
||||
// Compaction en cours de tour qui n'a rien pu réduire : la retenter
|
||||
// seulement une fois le contexte nettement plus gros (compactRetryDue).
|
||||
compactRetryAt := 0
|
||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
|
||||
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
|
||||
patternRetries := 0
|
||||
@@ -2468,7 +2471,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
publishHistory()
|
||||
}
|
||||
}
|
||||
if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) {
|
||||
if compactNeeded(messages, used, peakGen) && compactRetryDue(compactRetryAt, used, messages) &&
|
||||
!compactRefusedSkip(mainConv, used, messages) {
|
||||
opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol,
|
||||
disableTools || toolChoiceNone)
|
||||
yes, no := true, false
|
||||
@@ -2476,6 +2480,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
c, changed := compactMessagesNoted(ctx, messages, caps, opt)
|
||||
cb(StreamEvent{Compacting: &no})
|
||||
logCompact("en-tour", used, messages, c, changed)
|
||||
compactRetryAt = compactNextRetry(changed, used, messages)
|
||||
if changed {
|
||||
lastEst = 0 // estimation d'avant compaction : plus rien à comparer
|
||||
messages = c
|
||||
@@ -2810,7 +2815,7 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
|
||||
result, visionImg = toolCUScreenshot()
|
||||
default:
|
||||
if isMCPTool(name) {
|
||||
result = mcpCall(name, args)
|
||||
result = mcpCall(ctx, name, args)
|
||||
} else {
|
||||
result = "[erreur] outil inconnu: " + name
|
||||
}
|
||||
|
||||
+85
-23
@@ -25,6 +25,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -73,6 +74,14 @@ const (
|
||||
lspMaxDiags = 10 // diagnostics montrés au modèle
|
||||
)
|
||||
|
||||
// lspDiagQuiet : calme attendu après une publication avant de la croire
|
||||
// définitive. gopls, rust-analyzer et pyright publient souvent en plusieurs
|
||||
// fois pour une même version (liste vide d'abord, puis leur analyse, puis
|
||||
// celle d'un outil externe) : rendre la PREMIÈRE donnait « propre » à une
|
||||
// édition qui cassait la compilation. OpenFox attend 400 ms ; variable pour
|
||||
// les tests.
|
||||
var lspDiagQuiet = 350 * time.Millisecond
|
||||
|
||||
type lspServer struct {
|
||||
lang string
|
||||
root string
|
||||
@@ -82,11 +91,17 @@ type lspServer struct {
|
||||
nextID int
|
||||
version map[string]int // uri → version de document
|
||||
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
|
||||
diagMu sync.Mutex
|
||||
diags map[string][]lspDiagnostic
|
||||
diagMu sync.Mutex
|
||||
diags map[string][]lspDiagnostic
|
||||
// diagVer : version de document annoncée par la dernière publication de
|
||||
// l'uri, -1 quand le serveur n'en donne pas (champ facultatif du protocole).
|
||||
diagVer map[string]int
|
||||
diagCh chan string // uri fraîchement publié
|
||||
lastUsed time.Time
|
||||
dead bool
|
||||
// dead : atomique, pas sous mu. writeMsg le pose sur une écriture ratée, et
|
||||
// writeMsg tourne DANS check, qui tient déjà mu : le prendre là bloquait le
|
||||
// serveur (et le tour) à vie. lspGet le lit aussi sans mu.
|
||||
dead atomic.Bool
|
||||
}
|
||||
|
||||
type lspDiagnostic struct {
|
||||
@@ -214,7 +229,7 @@ func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
key := lang.ID + "\x00" + root
|
||||
lspMu.Lock()
|
||||
defer lspMu.Unlock()
|
||||
if s := lspServers[key]; s != nil && !s.dead {
|
||||
if s := lspServers[key]; s != nil && !s.dead.Load() {
|
||||
return s, nil
|
||||
}
|
||||
s, err := lspStart(lang, root)
|
||||
@@ -242,7 +257,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
}
|
||||
s := &lspServer{
|
||||
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{},
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
|
||||
diagCh: make(chan string, 16), lastUsed: time.Now(),
|
||||
}
|
||||
go s.readLoop(stdout)
|
||||
@@ -268,7 +283,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.dead {
|
||||
if s.dead.Load() {
|
||||
return nil, false
|
||||
}
|
||||
s.lastUsed = time.Now()
|
||||
@@ -277,16 +292,29 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
||||
return nil, false
|
||||
}
|
||||
uri := fileURI(absPath)
|
||||
// Signaux en attente : tous PÉRIMÉS. Un serveur qui republie après le
|
||||
// retour du check précédent laisse son uri dans le tampon ; lu ici tout de
|
||||
// suite après didChange, il faisait rendre « aucun diagnostic » avant même
|
||||
// que le serveur ait vu la nouvelle version.
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case <-s.diagCh:
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
|
||||
s.diagMu.Lock()
|
||||
delete(s.diags, uri)
|
||||
delete(s.diagVer, uri)
|
||||
s.diagMu.Unlock()
|
||||
v, open := s.version[uri]
|
||||
if !open {
|
||||
s.version[uri] = 1
|
||||
v = 1
|
||||
s.version[uri] = v
|
||||
s.notify("textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": s.lang, "version": 1, "text": string(content),
|
||||
"uri": uri, "languageId": s.lang, "version": v, "text": string(content),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -297,24 +325,56 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
||||
"contentChanges": []map[string]any{{"text": string(content)}},
|
||||
})
|
||||
}
|
||||
// Attend la publication pour CET uri, bornée.
|
||||
deadline := time.After(lspDiagWait)
|
||||
if s.dead.Load() {
|
||||
return nil, false // écriture ratée : le serveur ne verra jamais cette version
|
||||
}
|
||||
// current : les diagnostics publiés pour CETTE version (ou sans version),
|
||||
// pas ceux d'une version antérieure arrivés en retard.
|
||||
current := func() ([]lspDiagnostic, bool) {
|
||||
s.diagMu.Lock()
|
||||
defer s.diagMu.Unlock()
|
||||
d, ok := s.diags[uri]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if pv, has := s.diagVer[uri]; has && pv >= 0 && pv < v {
|
||||
return nil, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
// Attend la publication pour CET uri, puis un calme de lspDiagQuiet sans
|
||||
// nouvelle publication ; le tout borné par lspDiagWait.
|
||||
deadline := time.NewTimer(lspDiagWait)
|
||||
defer deadline.Stop()
|
||||
var quiet <-chan time.Time
|
||||
var quietT *time.Timer
|
||||
defer func() {
|
||||
if quietT != nil {
|
||||
quietT.Stop()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case got := <-s.diagCh:
|
||||
if got != uri {
|
||||
continue
|
||||
}
|
||||
s.diagMu.Lock()
|
||||
d := s.diags[uri]
|
||||
s.diagMu.Unlock()
|
||||
if _, ok := current(); !ok {
|
||||
continue // publication d'une version antérieure
|
||||
}
|
||||
if quietT == nil {
|
||||
quietT = time.NewTimer(lspDiagQuiet)
|
||||
} else {
|
||||
quietT.Reset(lspDiagQuiet)
|
||||
}
|
||||
quiet = quietT.C
|
||||
case <-quiet:
|
||||
d, _ := current()
|
||||
return d, true
|
||||
case <-deadline:
|
||||
case <-deadline.C:
|
||||
// Pas de publication à temps : certains serveurs ne publient rien
|
||||
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
|
||||
s.diagMu.Lock()
|
||||
d := s.diags[uri]
|
||||
s.diagMu.Unlock()
|
||||
d, _ := current()
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
@@ -353,6 +413,7 @@ func (s *lspServer) readLoop(r io.Reader) {
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
URI string `json:"uri"`
|
||||
Version *int `json:"version"`
|
||||
Diagnostics []lspDiagnostic `json:"diagnostics"`
|
||||
} `json:"params"`
|
||||
}
|
||||
@@ -362,6 +423,11 @@ func (s *lspServer) readLoop(r io.Reader) {
|
||||
if msg.Method == "textDocument/publishDiagnostics" {
|
||||
s.diagMu.Lock()
|
||||
s.diags[msg.Params.URI] = msg.Params.Diagnostics
|
||||
ver := -1
|
||||
if msg.Params.Version != nil {
|
||||
ver = *msg.Params.Version
|
||||
}
|
||||
s.diagVer[msg.Params.URI] = ver
|
||||
s.diagMu.Unlock()
|
||||
select {
|
||||
case s.diagCh <- msg.Params.URI:
|
||||
@@ -377,7 +443,7 @@ func (s *lspServer) idleWatch() {
|
||||
for range t.C {
|
||||
s.mu.Lock()
|
||||
idle := time.Since(s.lastUsed) > lspIdleTimeout
|
||||
dead := s.dead
|
||||
dead := s.dead.Load()
|
||||
s.mu.Unlock()
|
||||
if dead {
|
||||
return
|
||||
@@ -401,11 +467,7 @@ func (s *lspServer) shutdown() {
|
||||
lspMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *lspServer) markDead() {
|
||||
s.mu.Lock()
|
||||
s.dead = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *lspServer) markDead() { s.dead.Store(true) }
|
||||
|
||||
func (s *lspServer) send(method string, params any, isRequest bool) {
|
||||
s.nextID++
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeLSP : un serveur LSP réduit à ce que check utilise. Pour chaque
|
||||
// didOpen/didChange reçu, react(version, publish) décide des publications.
|
||||
type fakeLSP struct {
|
||||
s *lspServer
|
||||
out *io.PipeWriter
|
||||
}
|
||||
|
||||
func lspFrame(w io.Writer, v any) {
|
||||
b, _ := json.Marshal(v)
|
||||
fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(b), b)
|
||||
}
|
||||
|
||||
func newFakeLSP(t *testing.T, react func(ver int, publish func(ver *int, diags []lspDiagnostic))) *fakeLSP {
|
||||
t.Helper()
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
s := &lspServer{
|
||||
lang: "go", root: t.TempDir(), stdin: inW,
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
|
||||
diagCh: make(chan string, 16), lastUsed: time.Now(),
|
||||
}
|
||||
go s.readLoop(outR)
|
||||
go func() {
|
||||
br := bufio.NewReader(inR)
|
||||
for {
|
||||
n := 0
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
|
||||
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
|
||||
}
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(br, buf); err != nil {
|
||||
return
|
||||
}
|
||||
var msg struct {
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
TextDocument struct {
|
||||
URI string `json:"uri"`
|
||||
Version int `json:"version"`
|
||||
} `json:"textDocument"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if json.Unmarshal(buf, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
if msg.Method != "textDocument/didOpen" && msg.Method != "textDocument/didChange" {
|
||||
continue
|
||||
}
|
||||
uri := msg.Params.TextDocument.URI
|
||||
go react(msg.Params.TextDocument.Version, func(ver *int, diags []lspDiagnostic) {
|
||||
params := map[string]any{"uri": uri, "diagnostics": diags}
|
||||
if ver != nil {
|
||||
params["version"] = *ver
|
||||
}
|
||||
lspFrame(outW, map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params})
|
||||
})
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() { inW.Close(); outW.Close() })
|
||||
return &fakeLSP{s: s, out: outW}
|
||||
}
|
||||
|
||||
func lspErr(msg string) []lspDiagnostic {
|
||||
d := lspDiagnostic{Severity: 1, Message: msg}
|
||||
return []lspDiagnostic{d}
|
||||
}
|
||||
|
||||
func lspTempFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "main.go")
|
||||
if err := os.WriteFile(p, []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func withLSPQuiet(t *testing.T, d time.Duration) {
|
||||
old := lspDiagQuiet
|
||||
lspDiagQuiet = d
|
||||
t.Cleanup(func() { lspDiagQuiet = old })
|
||||
}
|
||||
|
||||
// Un signal laissé dans le tampon par une republication tardive ne doit pas
|
||||
// faire rendre « aucun diagnostic » à l'édition suivante.
|
||||
func TestLSPCheckIgnoresStaleSignal(t *testing.T) {
|
||||
withLSPQuiet(t, 80*time.Millisecond)
|
||||
p := lspTempFile(t)
|
||||
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
publish(&ver, lspErr("undefined: x"))
|
||||
})
|
||||
uri := fileURI(p)
|
||||
f.s.version[uri] = 1 // document déjà ouvert
|
||||
f.s.diagCh <- uri // republication tardive du check précédent
|
||||
f.s.diags[uri] = nil // …dont la liste était vide
|
||||
d, ok := f.s.check(p)
|
||||
if !ok || len(d) != 1 || d[0].Message != "undefined: x" {
|
||||
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur de la nouvelle version", d, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Une liste vide publiée d'abord (« clear ») puis l'analyse : c'est l'analyse
|
||||
// qui compte.
|
||||
func TestLSPCheckWaitsPastInitialClear(t *testing.T) {
|
||||
withLSPQuiet(t, 150*time.Millisecond)
|
||||
p := lspTempFile(t)
|
||||
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
|
||||
publish(&ver, nil)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
publish(&ver, lspErr("missing return"))
|
||||
})
|
||||
d, ok := f.s.check(p)
|
||||
if !ok || len(d) != 1 || d[0].Message != "missing return" {
|
||||
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur publiée après la liste vide", d, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Une publication d'une version antérieure, arrivée en retard, est ignorée.
|
||||
func TestLSPCheckIgnoresOlderVersion(t *testing.T) {
|
||||
withLSPQuiet(t, 80*time.Millisecond)
|
||||
p := lspTempFile(t)
|
||||
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
|
||||
old := ver - 1
|
||||
publish(&old, lspErr("ancienne erreur"))
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
publish(&ver, nil)
|
||||
})
|
||||
f.s.version[fileURI(p)] = 3
|
||||
d, ok := f.s.check(p)
|
||||
if !ok || len(d) != 0 {
|
||||
t.Fatalf("diagnostics = %+v, %v ; attendu aucun (seule la version 4 compte)", d, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans version dans la publication (champ facultatif), elle est prise.
|
||||
func TestLSPCheckAcceptsUnversionedPublish(t *testing.T) {
|
||||
withLSPQuiet(t, 50*time.Millisecond)
|
||||
p := lspTempFile(t)
|
||||
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
|
||||
publish(nil, lspErr("syntax error"))
|
||||
})
|
||||
start := time.Now()
|
||||
d, ok := f.s.check(p)
|
||||
if !ok || len(d) != 1 {
|
||||
t.Fatalf("diagnostics = %+v, %v", d, ok)
|
||||
}
|
||||
if time.Since(start) > lspDiagWait {
|
||||
t.Fatalf("check a attendu la borne au lieu du calme")
|
||||
}
|
||||
}
|
||||
|
||||
// Une écriture ratée vers le serveur ne bloque plus check (markDead prenait
|
||||
// mu, déjà tenu par check).
|
||||
func TestLSPCheckWriteFailureDoesNotDeadlock(t *testing.T) {
|
||||
p := lspTempFile(t)
|
||||
r, w := io.Pipe()
|
||||
r.Close() // toute écriture échoue
|
||||
s := &lspServer{
|
||||
lang: "go", stdin: w,
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
|
||||
diagCh: make(chan string, 16),
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.check(p)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(lspDiagWait + 2*time.Second):
|
||||
t.Fatal("check bloqué après une écriture ratée")
|
||||
}
|
||||
if !s.dead.Load() {
|
||||
t.Fatal("serveur pas marqué mort après une écriture ratée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// fakeMCPSlow branche un serveur MCP en mémoire dont l'outil « slow » ne rend
|
||||
// la main qu'à l'annulation de sa requête (ou au bout de 30 s).
|
||||
func fakeMCPSlow(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "lent", Version: "1"}, nil)
|
||||
srv.AddTool(&mcpsdk.Tool{Name: "slow", InputSchema: map[string]any{"type": "object"}},
|
||||
func(ctx context.Context, _ *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(30 * time.Second):
|
||||
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "fini"}}}, nil
|
||||
}
|
||||
})
|
||||
st, ct := mcpsdk.NewInMemoryTransports()
|
||||
ctx := context.Background()
|
||||
ss, err := srv.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs, err := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "loki-test", Version: "1"}, nil).Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := make(chan struct{})
|
||||
close(ready)
|
||||
name := mcpExposedName("lent", "slow")
|
||||
mcpMgr.mu.Lock()
|
||||
if mcpMgr.sessions == nil {
|
||||
mcpMgr.sessions = map[string]*mcpSession{}
|
||||
}
|
||||
if mcpMgr.registry == nil {
|
||||
mcpMgr.registry = map[string]mcpToolRef{}
|
||||
}
|
||||
mcpMgr.sessions["lent"] = &mcpSession{name: "lent", sess: cs, ready: ready}
|
||||
mcpMgr.registry[name] = mcpToolRef{server: "lent", tool: "slow"}
|
||||
mcpMgr.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
mcpMgr.mu.Lock()
|
||||
delete(mcpMgr.sessions, "lent")
|
||||
delete(mcpMgr.registry, name)
|
||||
mcpMgr.mu.Unlock()
|
||||
cs.Close()
|
||||
ss.Close()
|
||||
})
|
||||
return name
|
||||
}
|
||||
|
||||
// Le bouton stop annule l'appel MCP en cours au lieu d'attendre ses 120 s.
|
||||
func TestMCPCallHonorsTurnCancel(t *testing.T) {
|
||||
testHome(t)
|
||||
name := fakeMCPSlow(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
time.AfterFunc(150*time.Millisecond, cancel)
|
||||
start := time.Now()
|
||||
out := mcpCall(ctx, name, map[string]any{})
|
||||
if d := time.Since(start); d > 5*time.Second {
|
||||
t.Fatalf("l'appel a ignoré l'annulation du tour (%s)", d)
|
||||
}
|
||||
if !strings.Contains(out, "interrompu") {
|
||||
t.Fatalf("sortie = %q ; attendu un appel interrompu", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCallTimeoutFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
cfg MCPServerConfig
|
||||
args map[string]any
|
||||
want time.Duration
|
||||
}{
|
||||
{MCPServerConfig{}, nil, mcpCallTimeout},
|
||||
{MCPServerConfig{CallTimeoutSec: 600}, nil, 10 * time.Minute},
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 5000.0}, mcpCallTimeout}, // 5 s : le défaut reste
|
||||
{MCPServerConfig{}, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond
|
||||
{MCPServerConfig{CallTimeoutSec: 30}, nil, 30 * time.Second}, // plus court que le défaut
|
||||
}
|
||||
for i, c := range cases {
|
||||
if got := mcpCallTimeoutFor(c.cfg, c.args); got != c.want {
|
||||
t.Errorf("cas %d : %s, attendu %s", i, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -374,7 +375,11 @@ func isMCPTool(name string) bool {
|
||||
|
||||
// mcpCall exécute un outil MCP à partir de son nom exposé et renvoie une chaîne
|
||||
// résultat (texte aplati), cappée. Reconnecte une fois si la session est morte.
|
||||
func mcpCall(name string, args map[string]any) string {
|
||||
//
|
||||
// parent est le contexte DU TOUR, comme pour runShell : l'appel naissait d'un
|
||||
// context.Background(), donc le bouton stop n'arrêtait rien et le tour restait
|
||||
// bloqué jusqu'au bout des 120 s (OpenFox 2ef584b8).
|
||||
func mcpCall(parent context.Context, name string, args map[string]any) string {
|
||||
if facadeMCP() { // le pool est celui de la façade (facade_mcp.go)
|
||||
return facadeMCPCall(name, args)
|
||||
}
|
||||
@@ -384,35 +389,92 @@ func mcpCall(name string, args map[string]any) string {
|
||||
if !ok {
|
||||
return "[erreur] outil MCP inconnu ou serveur non connecté: " + name
|
||||
}
|
||||
servers, _ := LoadMCPConfig()
|
||||
cfg, exists := servers[ref.server]
|
||||
timeout := mcpCallTimeoutFor(cfg, args)
|
||||
|
||||
result, err := mcpCallOnce(ref, args)
|
||||
result, err := mcpCallOnce(parent, ref, args, timeout)
|
||||
if err != nil {
|
||||
if parent.Err() != nil {
|
||||
return "[appel MCP interrompu]"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Le serveur est vivant mais l'outil est lent : le relancer
|
||||
// doublerait l'attente (et l'effet de l'outil).
|
||||
return fmt.Sprintf("[erreur MCP] pas de réponse après %s (délai de l'appel ; un argument timeout plus grand l'allonge)", timeout.Round(time.Second))
|
||||
}
|
||||
// Session peut-être morte (process stdio tombé) : on l'invalide et on
|
||||
// retente une fois avec une reconnexion fraîche.
|
||||
servers, _ := LoadMCPConfig()
|
||||
cfg, exists := servers[ref.server]
|
||||
if exists && cfg.Enabled {
|
||||
mcpInvalidate(ref.server)
|
||||
mcpMgr.ensure(ref.server, cfg)
|
||||
if result2, err2 := mcpCallOnce(ref, args); err2 == nil {
|
||||
if result2, err2 := mcpCallOnce(parent, ref, args, timeout); err2 == nil {
|
||||
return result2
|
||||
} else {
|
||||
err = err2
|
||||
}
|
||||
if parent.Err() != nil {
|
||||
return "[appel MCP interrompu]"
|
||||
}
|
||||
}
|
||||
return "[erreur MCP] " + err.Error()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
|
||||
// mcpCallTimeoutMax plafonne le délai d'un appel, quoi que demandent l'outil
|
||||
// ou la config : au-delà, c'est un job d'arrière-plan, pas un appel d'outil.
|
||||
const mcpCallTimeoutMax = 30 * time.Minute
|
||||
|
||||
// mcpCallTimeoutFor : délai d'un appel. Défaut mcpCallTimeout, remplacé par
|
||||
// CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même
|
||||
// (beaucoup en ont un : navigateur, build, requête) l'allonge de 30 s pour que
|
||||
// l'outil ait le temps de rendre SA propre erreur de délai.
|
||||
func mcpCallTimeoutFor(cfg MCPServerConfig, args map[string]any) time.Duration {
|
||||
d := mcpCallTimeout
|
||||
if cfg.CallTimeoutSec > 0 {
|
||||
d = time.Duration(cfg.CallTimeoutSec) * time.Second
|
||||
}
|
||||
if t := mcpArgTimeout(args); t > 0 && t+30*time.Second > d {
|
||||
d = t + 30*time.Second
|
||||
}
|
||||
return min(d, mcpCallTimeoutMax)
|
||||
}
|
||||
|
||||
// mcpArgTimeout lit l'argument « timeout » d'un appel. Les serveurs MCP ne
|
||||
// s'accordent pas sur l'unité : au-delà de 3600 on le lit en millisecondes
|
||||
// (5000 = 5 s), en deçà en secondes (600 = 10 min).
|
||||
func mcpArgTimeout(args map[string]any) time.Duration {
|
||||
var v float64
|
||||
switch x := args["timeout"].(type) {
|
||||
case float64:
|
||||
v = x
|
||||
case int:
|
||||
v = float64(x)
|
||||
case int64:
|
||||
v = float64(x)
|
||||
case json.Number:
|
||||
v, _ = x.Float64()
|
||||
case string:
|
||||
v, _ = strconv.ParseFloat(strings.TrimSpace(x), 64)
|
||||
}
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 3600 {
|
||||
return time.Duration(v) * time.Millisecond
|
||||
}
|
||||
return time.Duration(v * float64(time.Second))
|
||||
}
|
||||
|
||||
func mcpCallOnce(parent context.Context, ref mcpToolRef, args map[string]any, timeout time.Duration) (string, error) {
|
||||
mcpMgr.mu.Lock()
|
||||
s := mcpMgr.sessions[ref.server]
|
||||
mcpMgr.mu.Unlock()
|
||||
if s == nil || s.sess == nil {
|
||||
return "", fmt.Errorf("serveur %s non connecté", ref.server)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpCallTimeout)
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
res, err := s.sess.CallTool(ctx, &mcpsdk.CallToolParams{Name: ref.tool, Arguments: args})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -83,7 +84,7 @@ func TestMCPEndToEnd(t *testing.T) {
|
||||
t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools))
|
||||
}
|
||||
|
||||
out := mcpCall(echoName, map[string]any{"message": "bonjour-loki"})
|
||||
out := mcpCall(context.Background(), echoName, map[string]any{"message": "bonjour-loki"})
|
||||
if !strings.Contains(out, "bonjour-loki") {
|
||||
t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ type MCPServerConfig struct {
|
||||
// non namespacé). Permet de garder un serveur connecté tout en masquant
|
||||
// certains de ses outils. Vide = tous les outils exposés.
|
||||
DisabledTools []string `json:"disabledTools,omitempty"`
|
||||
|
||||
// CallTimeoutSec : délai d'un appel d'outil de ce serveur, en secondes.
|
||||
// 0 = défaut (mcpCallTimeout). Pour un serveur dont les outils sont longs
|
||||
// par nature (build, scraping, requête lourde).
|
||||
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
|
||||
}
|
||||
|
||||
// ToolDisabled indique si un outil (nom réel) est masqué pour ce serveur.
|
||||
|
||||
@@ -299,9 +299,9 @@ func TestPariteMCP(t *testing.T) {
|
||||
results := map[string]string{}
|
||||
for _, n := range []string{"echo", "multi", "media", "link", "struct", "fail", "empty", "big", "titled", "secret", "dot_tool", "nope"} {
|
||||
name := mcpExposedName("alpha", n)
|
||||
results[name] = mcpCall(name, callArgs[n])
|
||||
results[name] = mcpCall(context.Background(), name, callArgs[n])
|
||||
}
|
||||
results["mcp__zeta__x"] = mcpCall("mcp__zeta__x", nil)
|
||||
results["mcp__zeta__x"] = mcpCall(context.Background(), "mcp__zeta__x", nil)
|
||||
fx["calls"] = results
|
||||
fx["prompt_line_live"] = mcpPromptLine(mcpTools())
|
||||
|
||||
|
||||
@@ -222,6 +222,10 @@ func cacheBust(bucket string) {
|
||||
cacheMu.Lock()
|
||||
delete(caches, bucket)
|
||||
cacheMu.Unlock()
|
||||
if bucket == bkState {
|
||||
webKeyCacheBust()
|
||||
retryStateBust()
|
||||
}
|
||||
}
|
||||
|
||||
// dbStamp renvoie la date et la taille du fichier de base — de quoi repérer
|
||||
|
||||
@@ -340,15 +340,24 @@ func TestRetryScanStartWindows(t *testing.T) {
|
||||
// Surcharge retry_patterns : ses motifs ne sont pas bornés, donc pas de fenêtre.
|
||||
func TestTextualToolCallFromCustomFullScan(t *testing.T) {
|
||||
retryPatterns()
|
||||
retryMu.Lock()
|
||||
saved := retryRes
|
||||
savedCustom := retryCustom
|
||||
defer func() { retryRes, retryCustom = saved, savedCustom }()
|
||||
retryRes, retryCustom = []*regexp.Regexp{regexp.MustCompile(`(?mi)\Aoups`)}, true
|
||||
retryChecked = time.Now() // pas de relecture pendant le test
|
||||
retryMu.Unlock()
|
||||
defer func() {
|
||||
retryMu.Lock()
|
||||
retryRes, retryCustom = saved, savedCustom
|
||||
retryMu.Unlock()
|
||||
}()
|
||||
s := "oups.\n" + strings.Repeat("Une phrase, puis une autre.\n", 400)
|
||||
if !textualToolCallFrom(s, len(s)-10) {
|
||||
t.Fatal("motif ancré en tête manqué")
|
||||
}
|
||||
retryMu.Lock()
|
||||
retryCustom = false
|
||||
retryMu.Unlock()
|
||||
if textualToolCallFrom(s, len(s)-10) {
|
||||
t.Fatal("la fenêtre aurait dû le manquer (le test ne prouve rien sinon)")
|
||||
}
|
||||
|
||||
@@ -1418,6 +1418,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
border:1px solid var(--border);border-radius:999px;background:var(--panel);
|
||||
color:var(--dim);font-size:12px;line-height:1.5}
|
||||
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
|
||||
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
|
||||
.katex{font-size:1.08em}
|
||||
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
|
||||
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
|
||||
|
||||
/* ===== Fenêtres et sélecteurs =====================================
|
||||
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
|
||||
@@ -3820,6 +3824,76 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
<script src="marked.min.js"></script>
|
||||
<script>
|
||||
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
|
||||
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
|
||||
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
|
||||
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
|
||||
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
|
||||
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
|
||||
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
|
||||
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
|
||||
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
|
||||
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
|
||||
// s'apparierait par-dessus le code en ligne.
|
||||
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
|
||||
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
|
||||
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
|
||||
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
|
||||
//
|
||||
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
|
||||
// exécute sous node sur les cas limites (montants, code, échappements).
|
||||
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
|
||||
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
|
||||
function mathExtract(s){
|
||||
const list = [];
|
||||
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
|
||||
if(code !== undefined) return all;
|
||||
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
|
||||
// pas d'espace avant le $ final.
|
||||
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
|
||||
const display = dd !== undefined || br !== undefined;
|
||||
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
|
||||
return '\uE000' + (list.length-1) + '\uE001';
|
||||
});
|
||||
return {text, list};
|
||||
}
|
||||
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
|
||||
// sa formule (render : formule → HTML).
|
||||
function mathRestore(html, list, render){
|
||||
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
|
||||
}
|
||||
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const MATH_OPTS = {throwOnError: false, output: 'html'};
|
||||
function mathHtml(f){
|
||||
if(window.katex){
|
||||
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
|
||||
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
|
||||
}
|
||||
mathLoad();
|
||||
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
|
||||
}
|
||||
let mathLoading = null;
|
||||
function mathLoad(){
|
||||
if(mathLoading) return mathLoading;
|
||||
if(!document.querySelector('link[data-katex]')){
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
|
||||
document.head.appendChild(css);
|
||||
}
|
||||
mathLoading = new Promise((ok, ko)=>{
|
||||
const s = document.createElement('script');
|
||||
s.src = 'katex/katex.min.js'; s.onload = ok;
|
||||
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
|
||||
s.onerror = ()=>{ s.remove(); ko(); };
|
||||
document.head.appendChild(s);
|
||||
}).then(()=>{
|
||||
document.querySelectorAll('.math-pending').forEach(el=>{
|
||||
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
|
||||
el.classList.remove('math-pending');
|
||||
});
|
||||
}).catch(()=>{ mathLoading = null; });
|
||||
return mathLoading;
|
||||
}
|
||||
// marked: GFM on, no auto-breaks (single newlines stay inline).
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
// Pre-process to fix common LLM markdown mistakes before handing to marked:
|
||||
@@ -3836,7 +3910,13 @@ function fixMd(s){
|
||||
s = s.replace(/\n{3,}/g, '\n\n');
|
||||
return s;
|
||||
}
|
||||
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
|
||||
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
|
||||
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
|
||||
function md(src){
|
||||
if(!src) return '';
|
||||
const m = mathExtract(fixMd(src));
|
||||
return mathRestore(marked.parse(m.text), m.list, mathHtml);
|
||||
}
|
||||
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
|
||||
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
|
||||
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&",">":">","<":"<",'"':""","'":"'"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
|
||||
@@ -0,0 +1,70 @@
|
||||
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
|
||||
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
|
||||
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
|
||||
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
|
||||
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
|
||||
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
|
||||
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
|
||||
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
|
||||
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
|
||||
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
|
||||
// s'apparierait par-dessus le code en ligne.
|
||||
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
|
||||
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
|
||||
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
|
||||
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
|
||||
//
|
||||
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
|
||||
// exécute sous node sur les cas limites (montants, code, échappements).
|
||||
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
|
||||
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
|
||||
function mathExtract(s){
|
||||
const list = [];
|
||||
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
|
||||
if(code !== undefined) return all;
|
||||
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
|
||||
// pas d'espace avant le $ final.
|
||||
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
|
||||
const display = dd !== undefined || br !== undefined;
|
||||
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
|
||||
return '\uE000' + (list.length-1) + '\uE001';
|
||||
});
|
||||
return {text, list};
|
||||
}
|
||||
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
|
||||
// sa formule (render : formule → HTML).
|
||||
function mathRestore(html, list, render){
|
||||
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
|
||||
}
|
||||
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const MATH_OPTS = {throwOnError: false, output: 'html'};
|
||||
function mathHtml(f){
|
||||
if(window.katex){
|
||||
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
|
||||
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
|
||||
}
|
||||
mathLoad();
|
||||
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
|
||||
}
|
||||
let mathLoading = null;
|
||||
function mathLoad(){
|
||||
if(mathLoading) return mathLoading;
|
||||
if(!document.querySelector('link[data-katex]')){
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
|
||||
document.head.appendChild(css);
|
||||
}
|
||||
mathLoading = new Promise((ok, ko)=>{
|
||||
const s = document.createElement('script');
|
||||
s.src = 'katex/katex.min.js'; s.onload = ok;
|
||||
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
|
||||
s.onerror = ()=>{ s.remove(); ko(); };
|
||||
document.head.appendChild(s);
|
||||
}).then(()=>{
|
||||
document.querySelectorAll('.math-pending').forEach(el=>{
|
||||
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
|
||||
el.classList.remove('math-pending');
|
||||
});
|
||||
}).catch(()=>{ mathLoading = null; });
|
||||
return mathLoading;
|
||||
}
|
||||
@@ -14,7 +14,13 @@ function fixMd(s){
|
||||
s = s.replace(/\n{3,}/g, '\n\n');
|
||||
return s;
|
||||
}
|
||||
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
|
||||
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
|
||||
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
|
||||
function md(src){
|
||||
if(!src) return '';
|
||||
const m = mathExtract(fixMd(src));
|
||||
return mathRestore(marked.parse(m.text), m.list, mathHtml);
|
||||
}
|
||||
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
|
||||
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
|
||||
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
|
||||
|
||||
@@ -1383,6 +1383,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
border:1px solid var(--border);border-radius:999px;background:var(--panel);
|
||||
color:var(--dim);font-size:12px;line-height:1.5}
|
||||
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
|
||||
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
|
||||
.katex{font-size:1.08em}
|
||||
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
|
||||
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
|
||||
|
||||
/* ===== Fenêtres et sélecteurs =====================================
|
||||
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// web_auth.go protège l'API de pilotage (loki web) quand elle est exposée sur
|
||||
@@ -19,7 +21,8 @@ import (
|
||||
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
|
||||
// protège llama-server / les complétions). On veut pouvoir donner à un client
|
||||
// un accès aux complétions sans lui donner le droit de redémarrer la machine,
|
||||
// et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un
|
||||
// et inversement. Elle est relue à chaque requête,
|
||||
// depuis un cache invalidé à chaque écriture (voir readWebKeyErr), pour qu'un
|
||||
// changement de clé prenne effet sans redémarrer le serveur web.
|
||||
|
||||
// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une
|
||||
@@ -27,9 +30,62 @@ import (
|
||||
// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était
|
||||
// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme
|
||||
// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir.
|
||||
//
|
||||
// requireWebAuth la consulte à CHAQUE requête : sans cache, chacune payait une
|
||||
// ouverture complète de la base sous dbMu (vignettes chargées deux par deux,
|
||||
// SSE, statut…), en concurrence avec l'écrivain des discussions (AJEAN #120).
|
||||
// On garde la dernière lecture RÉUSSIE, invalidée comme cachedKV : écriture de
|
||||
// ce process (cacheBust du bucket d'état), date/taille du fichier pour les
|
||||
// autres process (la CLI), une seconde au plus. Une lecture ratée n'est jamais
|
||||
// mise en cache : elle remonte, et l'API reste fermée.
|
||||
func readWebKeyErr() (string, error) {
|
||||
path := dbPath()
|
||||
mtime, size := dbStamp()
|
||||
c := &webKeyCache
|
||||
c.Lock()
|
||||
if c.ok && c.path == path && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge {
|
||||
k := c.key
|
||||
c.Unlock()
|
||||
return k, nil
|
||||
}
|
||||
gen := c.gen
|
||||
c.Unlock()
|
||||
b, err := getBytesErr(bkState, "web_key")
|
||||
return string(b), err
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Date et taille relevées APRÈS la lecture : ouvrir la base peut à lui seul
|
||||
// toucher le fichier (Windows), et un relevé d'avant ne correspondait alors
|
||||
// jamais. Une écriture externe glissée entre la lecture et le relevé reste
|
||||
// couverte par l'âge maximal.
|
||||
mtime, size = dbStamp()
|
||||
c.Lock()
|
||||
// Une invalidation pendant la lecture : la valeur lue est peut-être déjà
|
||||
// l'ancienne — ne pas la garder.
|
||||
if c.gen == gen {
|
||||
c.ok, c.path, c.key, c.when, c.mtime, c.size = true, path, string(b), time.Now(), mtime, size
|
||||
}
|
||||
c.Unlock()
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
var webKeyCache struct {
|
||||
sync.Mutex
|
||||
ok bool
|
||||
gen uint64 // incrémenté à chaque invalidation
|
||||
path string // la base lue (LOKI_HOME change d'un test à l'autre)
|
||||
key string
|
||||
when time.Time
|
||||
mtime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// webKeyCacheBust : appelé par cacheBust pour toute écriture du bucket d'état.
|
||||
func webKeyCacheBust() {
|
||||
webKeyCache.Lock()
|
||||
webKeyCache.ok = false
|
||||
webKeyCache.gen++
|
||||
webKeyCache.Unlock()
|
||||
}
|
||||
|
||||
// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// Une écriture de ce process est vue tout de suite : le cache ne retarde pas un
|
||||
// changement de clé.
|
||||
func TestWebKeyCacheInvalidatedByLocalWrite(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkState, "web_key", "ancienne"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, err := readWebKeyErr(); err != nil || k != "ancienne" {
|
||||
t.Fatalf("clé = %q, %v", k, err)
|
||||
}
|
||||
if err := putStr(bkState, "web_key", "nouvelle"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, err := readWebKeyErr(); err != nil || k != "nouvelle" {
|
||||
t.Fatalf("clé après écriture = %q, %v ; attendu la nouvelle", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Une écriture d'un AUTRE process (sans cacheBust ici) finit vue : au plus
|
||||
// cacheMaxAge plus tard.
|
||||
func TestWebKeyCacheSeesExternalWrite(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkState, "web_key", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, _ := readWebKeyErr(); k != "a" {
|
||||
t.Fatalf("clé = %q", k)
|
||||
}
|
||||
// Écriture directe, comme le ferait la CLI : pas de cacheBust.
|
||||
err := withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
return tx.Bucket([]byte(bkState)).Put([]byte("web_key"), []byte("b"))
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(cacheMaxAge + 500*time.Millisecond)
|
||||
for {
|
||||
if k, _ := readWebKeyErr(); k == "b" {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("écriture externe jamais vue")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans changement, les lectures suivantes n'ouvrent plus la base.
|
||||
func TestWebKeyCacheServesRepeatedReads(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkState, "web_key", "k"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, _ := readWebKeyErr(); k != "k" {
|
||||
t.Fatalf("clé = %q", k)
|
||||
}
|
||||
// Base verrouillée par « un autre process » : une lecture qui l'ouvrirait
|
||||
// attendrait la borne de 5 s puis échouerait.
|
||||
dbMu.Lock()
|
||||
type res struct {
|
||||
k string
|
||||
err error
|
||||
}
|
||||
ch := make(chan res, 1)
|
||||
go func() { k, err := readWebKeyErr(); ch <- res{k, err} }()
|
||||
select {
|
||||
case r := <-ch:
|
||||
dbMu.Unlock()
|
||||
if r.err != nil || r.k != "k" {
|
||||
t.Fatalf("clé = %q, %v", r.k, r.err)
|
||||
}
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
dbMu.Unlock()
|
||||
<-ch
|
||||
t.Fatal("lecture servie par la base, pas par le cache")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// web_katex.go — KaTeX 0.16.11 (formules LaTeX des réponses), repris d'AJEAN.
|
||||
//
|
||||
// Embarqué sous ui/katex/ et servi sous /katex/ : l'UI ne le charge qu'à la
|
||||
// première formule d'une réponse (00-md.js), et il doit marcher hors ligne,
|
||||
// sans appeler un CDN. Seuls le script, la feuille de style et les polices
|
||||
// woff2 sont servis — pas d'index de dossier — avec un Content-Type fixe : le
|
||||
// registre MIME de l'OS (Windows notamment) ne doit pas le décider, et la
|
||||
// façade Rust (rust/loki-rs/src/ui.rs) rend exactement les mêmes en-têtes.
|
||||
|
||||
// katexTypes : extensions servies et leur Content-Type.
|
||||
var katexTypes = map[string]string{
|
||||
".js": "application/javascript",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".woff2": "font/woff2",
|
||||
}
|
||||
|
||||
// katexFile : chemin relatif à ui/katex/ d'une ressource servable, ou "" si la
|
||||
// demande n'en est pas une (racine, dossier, autre extension, remontée).
|
||||
func katexFile(urlPath string) (rel, ctype string) {
|
||||
rel = strings.TrimPrefix(urlPath, "/katex/")
|
||||
if rel == urlPath || rel == "" || strings.Contains(rel, "..") || path.Clean(rel) != rel {
|
||||
return "", ""
|
||||
}
|
||||
ctype, ok := katexTypes[path.Ext(rel)]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
return rel, ctype
|
||||
}
|
||||
|
||||
func handleKatex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rel, ctype := katexFile(r.URL.Path)
|
||||
if rel == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, err := uiFS.ReadFile("ui/katex/" + rel)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
|
||||
w.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Détection des formules (ui/src/js/00-math.js), exécutée sous node : le dépôt
|
||||
// n'a pas de banc de tests JS, et MATH_RE/mathExtract sont purs. Ignoré si node
|
||||
// n'est pas installé (il l'est sur les runners de la CI).
|
||||
|
||||
type formule struct {
|
||||
Tex string `json:"tex"`
|
||||
Display bool `json:"display"`
|
||||
}
|
||||
|
||||
// harnaisMath : charge 00-math.js et marked.min.js, puis pour chaque entrée
|
||||
// (JSON sur stdin) rend {text, list} de mathExtract et, pour le bout en bout, le
|
||||
// HTML de marked après mathRestore (chaque formule rendue « [tex] »).
|
||||
const harnaisMath = `
|
||||
const fs = require('fs');
|
||||
const [mathPath, markedPath] = process.argv.slice(2);
|
||||
const m = new Function(fs.readFileSync(mathPath, 'utf8') + '; return {mathExtract, mathRestore};')();
|
||||
const {marked} = require(markedPath);
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
const cases = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
console.log(JSON.stringify(cases.map(s => {
|
||||
const r = m.mathExtract(s);
|
||||
const html = m.mathRestore(marked.parse(r.text), r.list, f => '[' + (f.display ? 'D:' : '') + f.tex + ']');
|
||||
return {text: r.text, list: r.list, html};
|
||||
})));
|
||||
`
|
||||
|
||||
type résultatMath struct {
|
||||
Text string `json:"text"`
|
||||
List []formule `json:"list"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
func lanceMath(t *testing.T, cas []string) []résultatMath {
|
||||
t.Helper()
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node absent : détection des formules non testée")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
h := filepath.Join(dir, "harnais.js")
|
||||
if err := os.WriteFile(h, []byte(harnaisMath), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
math, _ := filepath.Abs("ui/src/js/00-math.js")
|
||||
marked, _ := filepath.Abs("ui/marked.min.js")
|
||||
in, _ := json.Marshal(cas)
|
||||
cmd := exec.Command(node, h, math, marked)
|
||||
cmd.Stdin = strings.NewReader(string(in))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("node : %v\n%s", err, out)
|
||||
}
|
||||
var res []résultatMath
|
||||
if err := json.Unmarshal(out, &res); err != nil {
|
||||
t.Fatalf("sortie illisible : %v\n%s", err, out)
|
||||
}
|
||||
if len(res) != len(cas) {
|
||||
t.Fatalf("%d résultats pour %d cas", len(res), len(cas))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestMathDetection(t *testing.T) {
|
||||
in := func(tex string) formule { return formule{Tex: tex} }
|
||||
bloc := func(tex string) formule { return formule{Tex: tex, Display: true} }
|
||||
cas := []struct {
|
||||
src string
|
||||
want []formule
|
||||
}{
|
||||
// Montants : jamais des formules.
|
||||
{"Ça coûte 5 $ et 10 $.", nil},
|
||||
{"Entre $5 et $10 par mois.", nil},
|
||||
{"Prix : 5$ ou 6$, voire 7 $.", nil},
|
||||
{`Échappés : \$5 et \$6.`, nil},
|
||||
{"Collé à un mot : x$y$z.", nil},
|
||||
{"Espace finale : $x $.", nil},
|
||||
{"De 5 $, à 10$.", nil},
|
||||
{"Montant : 5 $, code `$x_1$`.", nil},
|
||||
// Code : jamais touché.
|
||||
{"En ligne `$x$` et `$$y$$`.", nil},
|
||||
{"```\n$x$ et $$y$$\n```", nil},
|
||||
{"~~~tex\n\\[a\\]\n~~~", nil},
|
||||
{"```\nbloc non fermé $x$", nil},
|
||||
// Formules.
|
||||
{"Soit $x^2 + y^2 = z^2$ ici.", []formule{in("x^2 + y^2 = z^2")}},
|
||||
{"Deux : $a$, $b$.", []formule{in("a"), in("b")}},
|
||||
{"$$\n\\int_0^1 x\\,dx\n$$", []formule{bloc(`\int_0^1 x\,dx`)}},
|
||||
{`En ligne \(a+b\) et en bloc \[c\].`, []formule{in("a+b"), bloc("c")}},
|
||||
{"Code `$x$` puis $y$.", []formule{in("y")}},
|
||||
{`Échappement dedans : $a\$b$.`, []formule{in(`a\$b`)}},
|
||||
}
|
||||
srcs := make([]string, len(cas))
|
||||
for i, c := range cas {
|
||||
srcs[i] = c.src
|
||||
}
|
||||
res := lanceMath(t, srcs)
|
||||
for i, c := range cas {
|
||||
got := res[i].List
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%q : %+v, attendu %+v", c.src, got, c.want)
|
||||
}
|
||||
if c.want == nil && res[i].Text != c.src {
|
||||
t.Errorf("%q : texte modifié en %q", c.src, res[i].Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bout en bout avec marked : \\, _ et * d'une formule arrivent intacts au rendu,
|
||||
// le markdown autour reste du markdown, et aucun repère ne survit.
|
||||
func TestMathAvecMarked(t *testing.T) {
|
||||
res := lanceMath(t, []string{
|
||||
"*Gras* : $a_1 * b_2$ et $$\\begin{matrix}a & b \\\\ c & d\\end{matrix}$$",
|
||||
"Montant : 5 $, code `$x_1$`.",
|
||||
})
|
||||
for _, want := range []string{"<em>Gras</em>", "[a_1 * b_2]", `[D:\begin{matrix}a & b \\ c & d\end{matrix}]`} {
|
||||
if !strings.Contains(res[0].HTML, want) {
|
||||
t.Errorf("HTML sans %q :\n%s", want, res[0].HTML)
|
||||
}
|
||||
}
|
||||
for _, r := range res {
|
||||
if strings.ContainsAny(r.HTML, "\uE000\uE001") {
|
||||
t.Errorf("repère resté dans le HTML : %q", r.HTML)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(res[1].HTML, "5 $") || !strings.Contains(res[1].HTML, "<code>$x_1$</code>") {
|
||||
t.Errorf("montant ou code abîmé : %s", res[1].HTML)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func getKatex(t *testing.T, méthode, chemin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handleKatex(rec, httptest.NewRequest(méthode, chemin, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
// Le script, embarqué, est servi sous /katex/ avec les en-têtes que rend aussi
|
||||
// la façade Rust (ui.rs).
|
||||
func TestKatexScriptServi(t *testing.T) {
|
||||
rec := getKatex(t, http.MethodGet, "/katex/katex.min.js")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code %d", rec.Code)
|
||||
}
|
||||
h := rec.Header()
|
||||
if got := h.Get("Content-Type"); got != "application/javascript" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := h.Get("Cache-Control"); got != "public, max-age=86400" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `version:"0.16.11"`) {
|
||||
t.Error("katex.min.js n'est pas KaTeX 0.16.11")
|
||||
}
|
||||
if h.Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
|
||||
t.Errorf("Content-Length = %q pour %d octets", h.Get("Content-Length"), rec.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// Chaque police woff2 que la feuille de style demande est embarquée et servie.
|
||||
func TestKatexFeuilleEtPolices(t *testing.T) {
|
||||
rec := getKatex(t, http.MethodGet, "/katex/katex.min.css")
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" {
|
||||
t.Fatalf("css : code %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
polices := regexp.MustCompile(`url\((fonts/[^)]+\.woff2)\)`).FindAllStringSubmatch(rec.Body.String(), -1)
|
||||
if len(polices) < 10 {
|
||||
t.Fatalf("%d polices woff2 dans la feuille", len(polices))
|
||||
}
|
||||
for _, m := range polices {
|
||||
r := getKatex(t, http.MethodGet, "/katex/"+m[1])
|
||||
if r.Code != http.StatusOK || r.Header().Get("Content-Type") != "font/woff2" || r.Body.Len() == 0 {
|
||||
t.Errorf("%s : code %d, type %q", m[1], r.Code, r.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rien d'autre : ni index de dossier, ni licence, ni remontée, ni POST.
|
||||
func TestKatexRefus(t *testing.T) {
|
||||
for _, c := range []struct{ méthode, chemin string }{
|
||||
{http.MethodGet, "/katex/"},
|
||||
{http.MethodGet, "/katex/fonts/"},
|
||||
{http.MethodGet, "/katex/LICENSE"},
|
||||
{http.MethodGet, "/katex/fonts/absente.woff2"},
|
||||
{http.MethodGet, "/katex/../index.html"},
|
||||
{http.MethodGet, "/katex/fonts/../katex.min.js"},
|
||||
{http.MethodGet, "/katexkatex.min.js"},
|
||||
{http.MethodPost, "/katex/katex.min.js"},
|
||||
} {
|
||||
if rec := getKatex(t, c.méthode, c.chemin); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s : code %d, attendu 404", c.méthode, c.chemin, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derrière gzipHandler (comme en production) : script et feuille compressés,
|
||||
// polices non — comme la façade.
|
||||
func TestKatexCompression(t *testing.T) {
|
||||
h := gzipHandler(http.HandlerFunc(handleKatex))
|
||||
for chemin, gz := range map[string]bool{
|
||||
"/katex/katex.min.js": true,
|
||||
"/katex/katex.min.css": true,
|
||||
"/katex/fonts/KaTeX_Main-Regular.woff2": false,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, chemin, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("Content-Encoding") == "gzip"; got != gz {
|
||||
t.Errorf("%s : gzip %v, attendu %v", chemin, got, gz)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
//go:generate go run ../../tools/assemble-ui ui
|
||||
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
|
||||
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2 ui/katex
|
||||
var uiFS embed.FS
|
||||
|
||||
// cmdWeb starts the HTTP server on the given port (default 8090).
|
||||
@@ -217,6 +217,9 @@ func newWebMux() *http.ServeMux {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Write(b)
|
||||
})
|
||||
// KaTeX (formules LaTeX des réponses, web_katex.go) : chargé par l'UI
|
||||
// seulement quand une réponse contient des maths. Embarqué, comme les polices.
|
||||
mux.HandleFunc("/katex/", handleKatex)
|
||||
// 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
|
||||
|
||||
@@ -42,6 +42,32 @@ fn main() {
|
||||
}
|
||||
out.push_str("];\n");
|
||||
|
||||
// KaTeX (formules LaTeX, repris d'AJEAN) : script, feuille et polices
|
||||
// woff2 sous ui/katex/, les polices nommées par leur chemin relatif
|
||||
// (« fonts/x.woff2 »), comme les sert web_katex.go.
|
||||
let katex = ui.join("katex");
|
||||
println!("cargo:rerun-if-changed={}", katex.display());
|
||||
for (ident, name) in [("KATEX_JS", "katex.min.js"), ("KATEX_CSS", "katex.min.css")] {
|
||||
let p = katex.join(name);
|
||||
assert!(p.is_file(), "fichier KaTeX manquant : {}", p.display());
|
||||
println!("cargo:rerun-if-changed={}", p.display());
|
||||
writeln!(out, "pub static {ident}: &[u8] = include_bytes!({p:?});").unwrap();
|
||||
}
|
||||
let katex_fonts = katex.join("fonts");
|
||||
println!("cargo:rerun-if-changed={}", katex_fonts.display());
|
||||
let mut kfonts: Vec<PathBuf> = std::fs::read_dir(&katex_fonts)
|
||||
.unwrap_or_else(|e| panic!("{} : {e}", katex_fonts.display()))
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "woff2"))
|
||||
.collect();
|
||||
kfonts.sort();
|
||||
out.push_str("pub static KATEX_FONTS: &[(&str, &[u8])] = &[\n");
|
||||
for p in &kfonts {
|
||||
let name = format!("fonts/{}", p.file_name().unwrap().to_str().unwrap());
|
||||
writeln!(out, " ({name:?}, include_bytes!({p:?})),").unwrap();
|
||||
}
|
||||
out.push_str("];\n");
|
||||
|
||||
let dst = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("ui_assets.rs");
|
||||
std::fs::write(dst, out).unwrap();
|
||||
}
|
||||
@@ -922,11 +922,19 @@ impl RetryPatterns {
|
||||
if over.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pats = over
|
||||
// Un motif vide (ou blanc) est écarté : `(?mi)` correspond à TOUT, et
|
||||
// chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
|
||||
// motif utilisable, les motifs embarqués, comme le Go
|
||||
// (compileRetryPatterns).
|
||||
let pats: Vec<Box<dyn RetryPattern>> = over
|
||||
.iter()
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.filter_map(|p| compile_go_regex(&format!("(?mi){p}")))
|
||||
.map(|r| Box::new(GoRegex(r)) as Box<dyn RetryPattern>)
|
||||
.collect();
|
||||
if pats.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(RetryPatterns::custom(pats))
|
||||
}
|
||||
|
||||
@@ -2907,10 +2915,19 @@ mod tests {
|
||||
/// en `(?mi)`, sémantique du Go (classes et limites de mot ASCII).
|
||||
#[test]
|
||||
fn surcharge_retry_patterns() {
|
||||
for raw in ["", "null", "[]", "[\"a\", 1]", "\"x\"", "[\"a\"] x"] {
|
||||
for raw in [
|
||||
"",
|
||||
"null",
|
||||
"[]",
|
||||
"[\"a\", 1]",
|
||||
"\"x\"",
|
||||
"[\"a\"] x",
|
||||
"[\"\"]",
|
||||
"[\" \", \"(\"]",
|
||||
] {
|
||||
assert!(RetryPatterns::from_override(raw).is_none(), "{raw}");
|
||||
}
|
||||
let p = RetryPatterns::from_override(r#"["^oups", "[", "\\bdo_\\w+\\("]"#).unwrap();
|
||||
let p = RetryPatterns::from_override(r#"["^oups", "[", "", "\\bdo_\\w+\\("]"#).unwrap();
|
||||
assert!(p.is_custom());
|
||||
assert_eq!(p.len(), 2, "la regex illisible est sautée");
|
||||
assert!(p.textual_tool_call("Bonjour\nOUPS !"));
|
||||
|
||||
@@ -25,6 +25,14 @@ pub struct ServerConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
|
||||
pub disabled_tools: Vec<String>,
|
||||
/// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en
|
||||
/// secondes ; 0 = défaut ([`CALL_TIMEOUT`]).
|
||||
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
|
||||
pub call_timeout_sec: i64,
|
||||
}
|
||||
|
||||
fn is_zero(n: &i64) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
@@ -37,6 +45,7 @@ impl Default for ServerConfig {
|
||||
headers: BTreeMap::new(),
|
||||
enabled: true,
|
||||
disabled_tools: Vec::new(),
|
||||
call_timeout_sec: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +87,7 @@ impl<'de> Deserialize<'de> for ServerConfig {
|
||||
Some(x) => x.as_bool().unwrap_or(false),
|
||||
},
|
||||
disabled_tools: list("disabledTools"),
|
||||
call_timeout_sec: v.get("callTimeoutSec").and_then(Value::as_i64).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -282,6 +292,53 @@ pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
|
||||
/// `mcpCallTimeout`.
|
||||
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
|
||||
/// config.
|
||||
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du
|
||||
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
|
||||
pub fn call_timeout_for(cfg: &ServerConfig, args: &Map<String, Value>) -> Duration {
|
||||
let mut d = CALL_TIMEOUT;
|
||||
if cfg.call_timeout_sec > 0 {
|
||||
d = Duration::from_secs(cfg.call_timeout_sec as u64);
|
||||
}
|
||||
if let Some(t) = arg_timeout(args) {
|
||||
let t = t + Duration::from_secs(30);
|
||||
if t > d {
|
||||
d = t;
|
||||
}
|
||||
}
|
||||
d.min(CALL_TIMEOUT_MAX)
|
||||
}
|
||||
|
||||
/// `mcpArgTimeout` : l'argument `timeout` d'un appel. Au-delà de 3600, lu en
|
||||
/// millisecondes (5000 = 5 s) ; en deçà, en secondes (600 = 10 min).
|
||||
fn arg_timeout(args: &Map<String, Value>) -> Option<Duration> {
|
||||
let v = match args.get("timeout")? {
|
||||
Value::Number(n) => n.as_f64()?,
|
||||
Value::String(s) => s.trim().parse::<f64>().ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
if !v.is_finite() || v <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if v > 3600.0 {
|
||||
// Plafonné bien avant tout débordement (CALL_TIMEOUT_MAX suit).
|
||||
return Some(Duration::from_millis(v.min(1e12) as u64));
|
||||
}
|
||||
Some(Duration::from_secs_f64(v))
|
||||
}
|
||||
|
||||
/// `mcpCall` pour un délai dépassé : le serveur est vivant mais l'outil est
|
||||
/// lent, pas de nouvel essai (il doublerait l'attente, et l'effet de l'outil).
|
||||
pub fn call_deadline_error(timeout: Duration) -> String {
|
||||
let secs = (timeout.as_millis() + 500) / 1000;
|
||||
format!(
|
||||
"[erreur MCP] pas de réponse après {} (délai de l'appel ; un argument timeout plus grand l'allonge)",
|
||||
crate::tools::shell::go_duration(Duration::from_secs(secs as u64))
|
||||
)
|
||||
}
|
||||
|
||||
/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans
|
||||
/// `.cmd`.
|
||||
@@ -463,6 +520,51 @@ fn resource_contents_json(v: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Les cas de `TestMCPCallTimeoutFor` côté Go.
|
||||
#[test]
|
||||
fn delai_d_appel() {
|
||||
let cfg = |n: i64| ServerConfig {
|
||||
call_timeout_sec: n,
|
||||
..Default::default()
|
||||
};
|
||||
let args = |v: Value| {
|
||||
let mut m = Map::new();
|
||||
m.insert("timeout".into(), v);
|
||||
m
|
||||
};
|
||||
let s = Duration::from_secs;
|
||||
let none = Map::new();
|
||||
assert_eq!(call_timeout_for(&cfg(0), &none), CALL_TIMEOUT);
|
||||
assert_eq!(call_timeout_for(&cfg(600), &none), s(600));
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330));
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230));
|
||||
assert_eq!(
|
||||
call_timeout_for(&cfg(0), &args(json!(5000.0))),
|
||||
CALL_TIMEOUT
|
||||
);
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!("400"))), s(430));
|
||||
assert_eq!(
|
||||
call_timeout_for(&cfg(0), &args(json!(86400.0 * 1000.0))),
|
||||
CALL_TIMEOUT_MAX
|
||||
);
|
||||
assert_eq!(call_timeout_for(&cfg(30), &none), s(30));
|
||||
assert_eq!(
|
||||
call_deadline_error(s(330)),
|
||||
"[erreur MCP] pas de réponse après 5m30s (délai de l'appel ; un argument timeout plus grand l'allonge)"
|
||||
);
|
||||
// Le champ survit à un aller-retour JSON, absent quand il vaut 0.
|
||||
let c: ServerConfig =
|
||||
serde_json::from_value(json!({"command": "x", "callTimeoutSec": 45})).unwrap();
|
||||
assert_eq!(c.call_timeout_sec, 45);
|
||||
assert_eq!(serde_json::to_value(&c).unwrap()["callTimeoutSec"], 45);
|
||||
assert!(
|
||||
serde_json::to_value(cfg(0))
|
||||
.unwrap()
|
||||
.get("callTimeoutSec")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noms() {
|
||||
assert_eq!(sanitize("mon serveur!"), "mon_serveur");
|
||||
|
||||
@@ -39,6 +39,9 @@ pub const INTERNAL_TOOLS: &str = "/internal/facade/v1/mcp/tools";
|
||||
pub const INTERNAL_CALL: &str = "/internal/facade/v1/mcp/call";
|
||||
pub const INTERNAL_PREWARM: &str = "/internal/facade/v1/mcp/prewarm";
|
||||
|
||||
/// Erreur d'un appel au délai dépassé (`context.DeadlineExceeded` du Go).
|
||||
const CALL_DEADLINE: &str = "context deadline exceeded";
|
||||
|
||||
/// Issue d'une tentative de connexion.
|
||||
#[derive(Default)]
|
||||
struct SlotState {
|
||||
@@ -251,15 +254,20 @@ impl Mcp {
|
||||
return format!("[erreur] outil MCP inconnu ou serveur non connecté: {name}");
|
||||
};
|
||||
let cap = config::output_cap(config::ctx_window(&store.config().await));
|
||||
let mut err = match self.call_once(&server, &tool, args, cap).await {
|
||||
let servers = Self::load(store).await;
|
||||
let timeout =
|
||||
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
|
||||
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
|
||||
Ok(r) => return r,
|
||||
Err(e) => e,
|
||||
};
|
||||
let servers = Self::load(store).await;
|
||||
if err == CALL_DEADLINE {
|
||||
return config::call_deadline_error(timeout);
|
||||
}
|
||||
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
|
||||
self.invalidate(&server).await;
|
||||
self.ensure(&server, cfg, version).await;
|
||||
match self.call_once(&server, &tool, args, cap).await {
|
||||
match self.call_once(&server, &tool, args, cap, timeout).await {
|
||||
Ok(r) => return r,
|
||||
Err(e) => err = e,
|
||||
}
|
||||
@@ -273,6 +281,7 @@ impl Mcp {
|
||||
tool: &str,
|
||||
args: &Map<String, Value>,
|
||||
cap: usize,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<String, String> {
|
||||
let slot = lock(&self.slots).get(server).cloned();
|
||||
let sess = slot
|
||||
@@ -281,9 +290,9 @@ impl Mcp {
|
||||
.and_then(|st| st.sess.clone())
|
||||
.filter(|s| !s.is_closed())
|
||||
.ok_or_else(|| format!("serveur {server} non connecté"))?;
|
||||
let res = tokio::time::timeout(config::CALL_TIMEOUT, sess.call_tool(tool, args))
|
||||
let res = tokio::time::timeout(timeout, sess.call_tool(tool, args))
|
||||
.await
|
||||
.map_err(|_| "context deadline exceeded".to_string())??;
|
||||
.map_err(|_| CALL_DEADLINE.to_string())??;
|
||||
let out = config::cap_output(config::flatten_content(&res), cap);
|
||||
if res.get("isError").and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(format!("[l'outil a renvoyé une erreur]\n{out}"));
|
||||
@@ -570,6 +579,7 @@ pub async fn serve(
|
||||
.collect(),
|
||||
enabled: d.opt_bool("enabled").unwrap_or(true),
|
||||
disabled_tools: Vec::new(),
|
||||
call_timeout_sec: 0,
|
||||
};
|
||||
let name = d.str("name");
|
||||
let res = mcp
|
||||
|
||||
@@ -48,6 +48,10 @@ static MANIFEST: Asset = Asset::new(
|
||||
"application/manifest+json",
|
||||
"public, max-age=3600",
|
||||
);
|
||||
/// KaTeX (formules LaTeX des réponses) : mêmes en-têtes que `web_katex.go`.
|
||||
const KATEX_CACHE: &str = "public, max-age=86400";
|
||||
static KATEX_JS: Asset = Asset::new(assets::KATEX_JS, "application/javascript", KATEX_CACHE);
|
||||
static KATEX_CSS: Asset = Asset::new(assets::KATEX_CSS, "text/css; charset=utf-8", KATEX_CACHE);
|
||||
|
||||
/// Réponse statique pour cette requête, ou `None` si la route n'est pas une
|
||||
/// ressource de l'UI (elle part alors au Go, qui rendra son 404 habituel).
|
||||
@@ -76,6 +80,9 @@ pub fn serve(method: &Method, path: &str, headers: &HeaderMap) -> Option<Respons
|
||||
}
|
||||
"/manifest.webmanifest" => asset(&MANIFEST, accepts_gzip),
|
||||
p if p.starts_with("/fonts/") => font(p)?,
|
||||
"/katex/katex.min.js" => asset(&KATEX_JS, accepts_gzip),
|
||||
"/katex/katex.min.css" => asset(&KATEX_CSS, accepts_gzip),
|
||||
p if p.starts_with("/katex/") => katex_font(p)?,
|
||||
_ => return None,
|
||||
};
|
||||
if accepts_gzip {
|
||||
@@ -130,6 +137,20 @@ fn font(path: &str) -> Option<Response<Body>> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// `/katex/fonts/<nom>.woff2` — chemin exact (pas le dernier segment seul,
|
||||
/// contrairement à `/fonts/`), comme `katexFile` dans `web_katex.go`. Le
|
||||
/// reste de `/katex/` part au Go, qui rend son 404.
|
||||
fn katex_font(path: &str) -> Option<Response<Body>> {
|
||||
let rel = path.strip_prefix("/katex/")?;
|
||||
let (_, body) = assets::KATEX_FONTS.iter().find(|(n, _)| *n == rel)?;
|
||||
let mut r = Response::new(full(Bytes::from_static(body)));
|
||||
let h = r.headers_mut();
|
||||
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("font/woff2"));
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(KATEX_CACHE));
|
||||
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
|
||||
Some(r)
|
||||
}
|
||||
|
||||
fn compress(b: &[u8]) -> Vec<u8> {
|
||||
let mut enc = GzEncoder::new(Vec::with_capacity(b.len() / 3), Compression::default());
|
||||
enc.write_all(b).expect("gzip en mémoire");
|
||||
@@ -193,6 +214,46 @@ mod tests {
|
||||
assert!(get("/fonts/inter.ttf", false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn katex() {
|
||||
let r = get("/katex/katex.min.js", true).unwrap();
|
||||
assert_eq!(hv(&r, "content-type"), "application/javascript");
|
||||
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
|
||||
assert_eq!(hv(&r, "content-encoding"), "gzip");
|
||||
assert!(
|
||||
String::from_utf8_lossy(assets::KATEX_JS).contains("version:\"0.16.11\""),
|
||||
"KaTeX 0.16.11 attendu"
|
||||
);
|
||||
let r = get("/katex/katex.min.css", false).unwrap();
|
||||
assert_eq!(hv(&r, "content-type"), "text/css; charset=utf-8");
|
||||
// Chaque police woff2 demandée par la feuille est servie, non compressée.
|
||||
let css = String::from_utf8_lossy(assets::KATEX_CSS);
|
||||
let polices: Vec<&str> = css
|
||||
.split("url(")
|
||||
.skip(1)
|
||||
.filter_map(|s| s.split(')').next())
|
||||
.filter(|u| u.ends_with(".woff2"))
|
||||
.collect();
|
||||
assert!(polices.len() >= 10, "{} polices", polices.len());
|
||||
for p in polices {
|
||||
let r = get(&format!("/katex/{p}"), true).unwrap_or_else(|| panic!("{p}"));
|
||||
assert_eq!(hv(&r, "content-type"), "font/woff2");
|
||||
assert_eq!(hv(&r, "content-encoding"), "");
|
||||
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
|
||||
}
|
||||
// Le reste part au Go (404) : ni index, ni licence, ni dernier segment seul.
|
||||
for p in [
|
||||
"/katex/",
|
||||
"/katex/LICENSE",
|
||||
"/katex/fonts/",
|
||||
"/katex/fonts/absente.woff2",
|
||||
"/katex/KaTeX_Main-Regular.woff2",
|
||||
"/katex/x/fonts/KaTeX_Main-Regular.woff2",
|
||||
] {
|
||||
assert!(get(p, false).is_none(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gzip_si_accepte() {
|
||||
let r = get("/", true).unwrap();
|
||||
|
||||
@@ -231,8 +231,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -304,7 +304,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -335,8 +335,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -358,8 +358,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -387,8 +387,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -410,8 +410,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -433,8 +433,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -542,8 +542,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -571,7 +571,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -615,7 +615,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -855,8 +855,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -947,8 +947,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -993,8 +993,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1022,7 +1022,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1066,7 +1066,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1103,8 +1103,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1132,8 +1132,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1241,8 +1241,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1270,7 +1270,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1314,7 +1314,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1554,8 +1554,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1646,8 +1646,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1686,8 +1686,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16402,
|
||||
"prompt_n": 32804,
|
||||
"prompt_ms": 16400.5,
|
||||
"prompt_n": 32801,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1715,7 +1715,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1759,7 +1759,7 @@
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<131201 octets e3226a117903>",
|
||||
"content": "<131191 octets 64f00ccc3d63>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1790,8 +1790,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16402,
|
||||
"prompt_n": 32804,
|
||||
"prompt_ms": 16400.5,
|
||||
"prompt_n": 32801,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1813,8 +1813,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16402,
|
||||
"prompt_n": 32804,
|
||||
"prompt_ms": 16400.5,
|
||||
"prompt_n": 32801,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1882,7 +1882,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2049,7 +2049,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2093,7 +2093,7 @@
|
||||
"max_tokens": 16,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<3745 octets 55a9fdff5d02>",
|
||||
"content": "<3744 octets dff015dced82>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2187,8 +2187,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -2216,7 +2216,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2260,7 +2260,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2291,8 +2291,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2314,8 +2314,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2368,7 +2368,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2420,8 +2420,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2443,8 +2443,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2497,7 +2497,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2541,7 +2541,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2572,8 +2572,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2595,8 +2595,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2649,7 +2649,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2693,7 +2693,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2747,8 +2747,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2770,8 +2770,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2824,7 +2824,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2868,7 +2868,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2992,8 +2992,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3015,8 +3015,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3069,7 +3069,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3121,8 +3121,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3144,8 +3144,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3189,8 +3189,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3218,7 +3218,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3287,7 +3287,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3319,8 +3319,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3342,8 +3342,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3383,8 +3383,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3412,7 +3412,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3457,7 +3457,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3489,8 +3489,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3512,8 +3512,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3579,8 +3579,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3608,7 +3608,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3652,7 +3652,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3737,8 +3737,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3760,8 +3760,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3823,7 +3823,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163781 octets 608b16075e1c>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3867,7 +3867,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8144 octets 5c98ee6b6194>",
|
||||
"content": "<8103 octets e6352865574a>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3952,8 +3952,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3975,8 +3975,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4034,8 +4034,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4057,8 +4057,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4089,8 +4089,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4112,8 +4112,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4135,8 +4135,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1020,
|
||||
"prompt_n": 2040,
|
||||
"prompt_ms": 1014.5,
|
||||
"prompt_n": 2029,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user