Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5.5 6a6911af27 Bench de la façade : journal régénéré pour le nouvel index.html
Le banc d'essai prend l'index.html embarqué comme corpus de code et en
coupe un morceau au milieu du fichier : les formules LaTeX (CSS et
00-math.js) déplacent ce milieu. Journal tiré sous Linux
(LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBench) ;
seules changent les tailles et empreintes des textes et les prompt_n
qui en découlent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:28:50 +02:00
MichaelandClaude Opus 5.5 965a4d6247 Formules LaTeX rendues dans les réponses (KaTeX, repris d'AJEAN #123)
00-math.js : $…$, $$…$$, \(…\) et \[…\] mis de côté avant marked (qui
abîmerait \, _ et *), remplacés par un repère puis par le rendu KaTeX.
KaTeX n'est chargé qu'à la première formule ; d'ici là la source
s'affiche, puis est rendue sur place. Le code (blocs et en ligne) et les
montants (« 5 $ et 10 $ », « $5 et $10 », \$) restent du texte.

Logique de détection d'AJEAN, avec deux garde-fous en plus : un $ ouvrant
précédé d'un chiffre et d'une espace est un montant (« 5 $, à 10$ »), et
$…$ ne contient pas de backtick — sans quoi « 5 $, code `$x$` »
s'appariait par-dessus le code en ligne (le cas passe aussi chez AJEAN).

Fonctions pures dans un fichier à part : web_katex_math_test.go les
exécute sous node (ignoré sans node) sur les cas limites, et de bout en
bout avec marked. index.html régénéré par tools/assemble-ui ; NOTICE.md
mentionne la reprise et la licence MIT de KaTeX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:16 +02:00
MichaelandClaude Opus 5.5 99c96a6c33 KaTeX 0.16.11 embarqué et servi sous /katex/ (Go et façade)
Repris d'AJEAN (d6136caa, #123) : script, feuille de style, polices woff2
et licence MIT sous internal/loki/ui/katex/, embarqués par le go:embed et
par build.rs de la façade. Pas de CDN : l'interface doit marcher hors
ligne.

Route /katex/ des deux côtés avec les mêmes en-têtes : katex.min.js
(application/javascript), katex.min.css (text/css), fonts/*.woff2
(font/woff2), Cache-Control public 1 jour ; script et feuille compressés
en gzip, polices non. Contrairement à AJEAN (http.FileServer), le Go
(web_katex.go) ne sert que ces fichiers, avec un Content-Type fixe : ni
index de dossier, ni type décidé par le registre MIME de l'OS. La façade
(ui.rs) rend les mêmes réponses ; le reste de /katex/ part au Go (404).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:16 +02:00
LogiFlow 6a45f19831 Merge pull request #34 from R0m1k3/fix/lot1-ajean-openfox
Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
2026-10-19 11:22:20 +02:00
MichaelandClaude Opus 5.5 4c2cf8b4e6 Façade : délai d'appel MCP par serveur et par argument, pas de relance au délai dépassé
Même règle que le Go (mcpCallTimeoutFor) : 120 s par défaut, remplacés par
callTimeoutSec du serveur, allongés par l'argument timeout de l'outil (+30 s,
millisecondes au-delà de 3600), plafond 30 min. Un délai dépassé rend la même
erreur que le Go sans reconnecter ni relancer l'outil. callTimeoutSec est lu
et réécrit tel quel dans la configuration des serveurs : un enregistrement
par la façade ne le perd plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 68dd65cd5a Façade : motifs de relance vides écartés, comme le Go
Un motif vide (ou blanc) dans retry_patterns donnait (?mi), qui correspond à
tout : chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
motif utilisable, la façade retombe sur les motifs embarqués, comme
compileRetryPatterns côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 01c11be4ae Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
- LSP (lsp.go) : un signal resté dans le tampon par une republication
  tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même
  que le serveur ait vu la nouvelle version. Le canal est vidé, une
  publication d'une version antérieure est ignorée, et check attend 350 ms de
  calme après la dernière publication (liste vide puis analyse : c'est
  l'analyse qui compte), toujours borné par lspDiagWait. Au passage :
  interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par
  check) — dead devient atomique.
- run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan
  sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui
  l'effaçait (AJEAN 7403a8a0).
- Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir
  la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par
  toute écriture du bucket d'état, par la date/taille du fichier (autre
  process) et au bout d'une seconde.
- MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par
  serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil,
  plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8).
- Recall : les blocs d'une compaction s'archivent en une seule transaction
  au lieu d'une par bloc (AJEAN #130).
- Compactage en cours de tour : après un essai qui n'a rien réduit, pas de
  nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et
  sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN
  68659e73).
- Motifs de relance : un motif vide (qui correspondait à tout) est écarté,
  une surcharge sans motif utilisable retombe sur l'embarqué, et un
  changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869,
  c52c5bf7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
59 changed files with 1816 additions and 240 deletions

No files matched your search

+15
View File
@@ -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
+56 -6
View File
@@ -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
+32
View File
@@ -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")
}
}
+65 -35
View File
@@ -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.
+44
View File
@@ -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)
}
}
+7 -3
View File
@@ -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)
}
}
+11
View File
@@ -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
View File
@@ -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:]
+47
View File
@@ -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é")
}
}
+4 -3
View File
@@ -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 {
+7 -2
View File
@@ -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
View File
@@ -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++
+200
View File
@@ -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")
}
}
+96
View File
@@ -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)
}
}
}
+69 -7
View File
@@ -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 {
+2 -1
View File
@@ -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)
}
+5
View File
@@ -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.
+2 -2
View File
@@ -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())
+4
View File
@@ -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
+10 -1
View File
@@ -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)")
}
+81 -1
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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
+21
View File
@@ -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.
+1
View File
@@ -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
+1
View File
@@ -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={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},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]*([^\\/#?]*?)(:|&#0*58|&#x0*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
+70
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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;
}
+7 -1
View File
@@ -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
+4
View File
@@ -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é. */
+58 -2
View File
@@ -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
+88
View File
@@ -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")
}
}
+59
View File
@@ -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)
}
+143
View File
@@ -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)
}
}
+94
View File
@@ -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)
}
}
}
+4 -1
View File
@@ -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
+26
View File
@@ -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();
}
+20 -3
View File
@@ -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 !"));
+102
View File
@@ -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");
+15 -5
View File
@@ -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
+61
View File
@@ -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();
+125 -125
View File
@@ -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
}