Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 d5140f3b3a Banc d'essai : journal régénéré après le changement de l'UI
Le banc coupe son corpus au milieu de l'index.html embarqué : les deux
lignes de la carte « Serveurs MCP » et le champ de la fenêtre d'édition le
déplacent. Journal tiré sous Linux (LOKI_PARITY_UPDATE=1, TestPariteBench).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:24:27 +02:00
MichaelandClaude Opus 5.5 f4026506ff Façade : délais MCP globaux et délai par serveur, comme le Go
La façade lit mcp_timeouts pour se connecter (au moins 3 min pour npx/uvx)
et pour chaque appel, sert GET /api/mcp/timeouts et
POST /api/mcp/timeouts/save avec les mêmes refus que le Go, et ferme ses
sessions à l'enregistrement. /api/mcp/save accepte callTimeoutSec (0 à
3600) au lieu de le remettre à zéro, et /api/mcp le renvoie. Plafond d'un
appel porté à 1 h. tests/mcp.rs rejoue le journal tiré du Go : lecture des
délais, effets, routes, sessions fermées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:30 +02:00
MichaelandClaude Opus 5.5 2e28ca955e MCP : délais de connexion et d'appel réglables dans l'interface
Repris d'AJEAN c05a0a9f et e44b6139. Les deux délais MCP, jusqu'ici codés en
dur (20 s pour se connecter, 120 s pour un appel d'outil), deviennent des
réglages globaux : clé bkState mcp_timeouts, bornes 1 à 3600 s, routes
GET /api/mcp/timeouts et POST /api/mcp/timeouts/save. L'enregistrement ferme
les sessions ouvertes pour que le nouveau délai de connexion s'applique dès
la suivante. Un lancement npx/uvx garde au moins mcpConnectTimeoutCold.

Un appel prend le réglage global, toujours remplacé par le callTimeoutSec du
serveur, puis allongé par l'argument timeout de l'outil. Le plafond d'un
appel suit la borne des réglages (1 h au lieu de 30 min) : un délai réglé à
3600 s n'est plus rogné en silence. Une valeur rangée hors bornes ou
illisible retombe sur la borne ou le défaut.

Le délai par serveur passe par /api/mcp/save (callTimeoutSec, 0 à 3600,
vide = réglage global), revient dans /api/mcp et n'est plus perdu à
l'enregistrement. L'interface gagne deux lignes dans la carte « Serveurs
MCP », au style des réglages (sans bordure, un seul enregistrement par
modification), et un champ « délai d'un appel d'outil » dans la fenêtre
d'édition d'un serveur. index.html régénéré par tools/assemble-ui.

Quand la façade tient les serveurs MCP (LOKI_MCP=rust), elle sert aussi
les nouvelles routes : c'est son pool qu'il faut fermer quand les délais
changent. Le journal de parité MCP couvre la lecture des délais, leur effet
sur la connexion et l'appel, et les deux routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:14 +02:00
29 changed files with 1545 additions and 2038 deletions

No files matched your search

-8
View File
@@ -52,14 +52,6 @@ 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.
La **recherche plein-texte des discussions** (`chat_search.go`) reprend celle
de l'historique d'AJEAN (commit `558d3b4d`, issue amont #98, PR #99 d'Olioli4) :
index inversé chiffré comme les discussions, tokens repliés sans accents,
correspondance par début de mot, titre compté double. Adaptée au modèle de
Loki (toutes les discussions dans `bkChat`, index tenu à chaque écriture, y
compris celles de la façade Rust) et à sa barre latérale (« rechercher
partout », extrait autour du passage trouvé).
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
-3
View File
@@ -173,9 +173,6 @@ func (p *convPersister) run() {
sort.Slice(snaps, func(i, j int) bool { return snaps[i].seq < snaps[j].seq })
wrote, err := persistCommitByPath(snaps, res)
// Index plein-texte (chat_search.go) AVANT de marquer le lot traité : un
// flush attend donc aussi l'index, et une suppression passe toujours après.
histIndexWritten(wrote)
p.mu.Lock()
for _, j := range res {
-852
View File
@@ -1,852 +0,0 @@
package loki
// chat_search.go — recherche PLEIN-TEXTE dans les discussions (reprise d'AJEAN,
// commit 558d3b4d, issue amont #98 / PR #99).
//
// Avant, la barre latérale ne filtrait que les TITRES, côté navigateur. Relire
// le corps de chaque discussion à chaque frappe est exclu : une discussion pèse
// vite des centaines de Ko, chiffrée de surcroît. On tient donc un INDEX INVERSÉ
// dans bkChatSearch, à trois familles de clés :
//
// « d|<id> » → histSearchDoc : la fiche d'une discussion (version, date
// d'indexation, aperçu). Petite : la couverture et l'aperçu se
// lisent sans charger la liste des mots.
// « t|<id> » → []string : les mots DISTINCTS de la discussion, repliés et
// triés. Sert seulement à la mise à jour (différence avec
// l'état précédent) et à la suppression.
// « p|<token> » → []string d'identifiants triés (sémantique d'ENSEMBLE).
//
// Écart avec l'amont : AJEAN range la liste des mots dans la fiche « d| » et
// lit TOUTES les fiches à chaque requête pour la couverture. Ici la fiche reste
// petite, et une mise à jour ne touche que les mots ajoutés ou retirés depuis la
// fois précédente — la discussion ouverte est réindexée à chaque écriture, pas
// seulement à l'archivage comme chez l'amont.
//
// QUI ÉCRIT L'INDEX. Ce process, toujours, même quand la façade Rust tient la
// conversation (LOKI_CHAT=rust) : elle n'a pas accès à la base et écrit ses
// discussions par /internal/v1/store (web_internal.go), qui indexe au passage
// (histIndexStoreWrite). L'index suit donc la même discussion quel que soit le
// process qui la tient, sans second écrivain ni test de parité à tenir : la
// route de recherche (/api/conversations/search) est servie ICI, la façade la
// relaie comme toute route qu'elle ne connaît pas (proxy.rs, forward).
//
// ACCENTS : l'index stocke des tokens REPLIÉS (« résumé » → « resume »). C'est
// délibérément différent de la sortie de recallTokens, que l'outil recall_search
// du modèle consomme telle quelle — ne pas « corriger » cette apparente
// incohérence : replier l'index (et la requête, symétriquement) est ce qui fait
// qu'une recherche « resume » trouve « résumé » et inversement.
import (
"bytes"
"encoding/json"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"
bolt "go.etcd.io/bbolt"
)
const (
// histSearchIndexVersion : une fiche dont la version diffère est traitée
// comme non indexée et reconstruite en entier.
histSearchIndexVersion = 1
// histSearchMaxTerms borne le nombre de postings lus par requête.
histSearchMaxTerms = 16
// histSearchDefaultLimit / histSearchMaxLimit : taille d'une page de
// résultats. L'extrait en contexte relit le corps de CHAQUE résultat de la
// page : la borne protège la requête d'un « limit=100000 ».
histSearchDefaultLimit = 30
histSearchMaxLimit = 100
)
// histSearchDoc = la fiche d'index d'une discussion.
type histSearchDoc struct {
V int `json:"v"`
At int64 `json:"at"` // indexée à (Unix, s) — comparée à convMeta.Updated
Apercu string `json:"apercu,omitempty"` // début du texte visible, borné (recallSnippet)
}
// histSearchHit = une discussion trouvée, telle que la reçoit l'UI.
type histSearchHit struct {
ID string `json:"id"`
Title string `json:"title"`
Created int64 `json:"created"`
Updated int64 `json:"updated"`
Turns int `json:"turns"`
Score int `json:"score"`
InTitle bool `json:"in_title"`
Snippet string `json:"snippet"`
}
// histSearchResult = le résultat complet d'une recherche.
type histSearchResult struct {
State string `json:"state"` // ok | locked | no_terms
Hits []histSearchHit `json:"conversations"`
Total int `json:"total"` // correspondances AVANT pagination
Indexed int `json:"indexed"` // discussions du projet réellement indexées
IndexedTotal int `json:"indexed_total"` // discussions indexables du projet
Terms []string `json:"terms"` // termes cherchés (repliés, triés)
}
func histDocKey(id string) string { return "d|" + id }
func histToksKey(id string) string { return "t|" + id }
func histPostKey(tok string) string { return "p|" + tok }
// foldHistAccents replie les diacritiques d'un token. Appliqué SYMÉTRIQUEMENT
// à l'indexation et à la requête (voir le commentaire de tête).
func foldHistAccents(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case 'à', 'â', 'ä', 'á', 'ã', 'å':
b.WriteByte('a')
case 'é', 'è', 'ê', 'ë':
b.WriteByte('e')
case 'î', 'ï', 'í', 'ì':
b.WriteByte('i')
case 'ô', 'ö', 'ó', 'ò', 'õ':
b.WriteByte('o')
case 'ù', 'û', 'ü', 'ú':
b.WriteByte('u')
case 'ç':
b.WriteByte('c')
case 'ÿ':
b.WriteByte('y')
case 'œ':
b.WriteString("oe")
case 'æ':
b.WriteString("ae")
default:
b.WriteRune(r)
}
}
return b.String()
}
// histTokens dérive les mots d'index d'une chaîne : recallTokens (réutilisé,
// jamais modifié), puis repli des accents et dédoublonnage, triés. Deux tokens
// distincts peuvent se replier sur le même (« cote » et « côté »).
func histTokens(s string) []string {
set := recallTokens(s)
seen := make(map[string]bool, len(set))
out := make([]string, 0, len(set))
for t := range set {
f := foldHistAccents(t)
if seen[f] {
continue
}
seen[f] = true
out = append(out, f)
}
sort.Strings(out)
return out
}
// histTokenSet : variante « ensemble » de histTokens (titre, discussion ouverte).
func histTokenSet(s string) map[string]bool {
out := map[string]bool{}
for _, t := range histTokens(s) {
out[t] = true
}
return out
}
// histTermMatches est LA règle de correspondance : un token correspond à un
// terme quand il COMMENCE par lui, jamais au milieu du mot (« eepseek » ne
// trouve pas « deepseek »). Servie au titre, à la discussion ouverte et à
// l'extrait ; le scan des postings applique la même règle au niveau de la clé
// (préfixe « p|<terme> »).
func histTermMatches(token, term string) bool {
return strings.HasPrefix(token, term)
}
// histSetMatches : un terme correspond-il à l'un des tokens d'un ensemble ?
// Vrai au plus UNE fois par terme : le score compte les TERMES, pas les tokens.
func histSetMatches(set map[string]bool, term string) bool {
for tok := range set {
if histTermMatches(tok, term) {
return true
}
}
return false
}
// histQueryTerms replie, trie et plafonne les termes de la requête.
func histQueryTerms(q string) []string {
terms := histTokens(q)
if len(terms) > histSearchMaxTerms {
terms = terms[:histSearchMaxTerms]
}
return terms
}
// histVisibleText concatène le texte VISIBLE d'un journal d'affichage, seule
// source indexée. On part du journal (vue UI) et NON des Messages : leur
// contenu peut être multimodal et porte le CORPS des sorties d'outil, qu'on ne
// veut pas indexer. On retient le message de l'utilisateur (« user »), la
// réponse (« content ») et le libellé d'outil (« tool_used.label »). Exclus :
// raisonnement, résultat d'outil, fichiers, deltas de contrôle.
func histVisibleText(log []LogEvent) string {
var b strings.Builder
for _, ev := range log {
if s, ok := ev.Delta["user"].(string); ok {
b.WriteString(s)
b.WriteByte('\n')
}
if s, ok := ev.Delta["content"].(string); ok {
b.WriteString(s)
b.WriteByte('\n')
}
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
if l, ok := tu["label"].(string); ok {
b.WriteString(l)
b.WriteByte('\n')
}
}
}
return b.String()
}
// histFirstVisible : premier texte visible d'un journal (aperçu de la fiche).
func histFirstVisible(log []LogEvent) string {
for _, ev := range log {
if s, ok := ev.Delta["user"].(string); ok && strings.TrimSpace(s) != "" {
return s
}
if s, ok := ev.Delta["content"].(string); ok && strings.TrimSpace(s) != "" {
return s
}
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
if l, ok := tu["label"].(string); ok && strings.TrimSpace(l) != "" {
return l
}
}
}
return ""
}
// histLogOf extrait le journal d'affichage du JSON d'une Conversation, sans
// rien décoder d'autre.
func histLogOf(body []byte) ([]LogEvent, bool) {
var c struct {
Log []LogEvent `json:"log"`
}
if json.Unmarshal(body, &c) != nil {
return nil, false
}
return c.Log, true
}
// histIsWordRune : même découpage que recallTokens (sur la chaîne en minuscules).
func histIsWordRune(r rune) bool {
return r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ'
}
// histContextSnippet renvoie un extrait du texte autour du PREMIER mot qui
// correspond à l'un des termes (même règle de préfixe), ou "" sans
// correspondance. L'aperçu de la fiche ne montre que le début d'une
// discussion — c'est-à-dire, le plus souvent, son titre.
func histContextSnippet(text string, terms []string) string {
const before, after = 60, 140
runes := []rune(text)
lower := make([]rune, len(runes)) // rune par rune : les positions restent alignées
for i, r := range runes {
lower[i] = unicode.ToLower(r)
}
for i := 0; i < len(lower); {
if !histIsWordRune(lower[i]) {
i++
continue
}
j := i
for j < len(lower) && histIsWordRune(lower[j]) {
j++
}
if j-i >= 2 {
w := foldHistAccents(string(lower[i:j]))
for _, t := range terms {
if !histTermMatches(w, t) {
continue
}
from, to := max(0, i-before), min(len(runes), j+after)
// Couper sur une espace pour ne pas commencer ni finir au milieu d'un mot.
for from > 0 && from < i && runes[from-1] != ' ' && runes[from-1] != '\n' {
from++
}
for to < len(runes) && to > j && runes[to] != ' ' && runes[to] != '\n' {
to--
}
s := strings.Join(strings.Fields(string(runes[from:to])), " ")
if from > 0 {
s = "…" + s
}
if to < len(runes) {
s += "…"
}
return s
}
}
i = j
}
return ""
}
// --- accès bas niveau au bucket (chiffré comme les autres) --------------------
// histTxGetJSON lit et déchiffre une valeur dans une transaction ouverte.
// decodeMemContent est sûr ici : sur du clair il ne lit rien, sur du chiffré
// il n'appelle que currentDEK (mutex en RAM, jamais la base).
func histTxGetJSON(b *bolt.Bucket, key string, dst any) bool {
raw := b.Get([]byte(key))
if raw == nil {
return false
}
plain, err := decodeMemContent(raw)
if err != nil {
return false // chiffré + verrouillé : illisible, pas « absent »
}
return json.Unmarshal(plain, dst) == nil
}
// histTxPutJSON sérialise, chiffre (encodeur résolu HORS transaction : bbolt
// n'est pas rentrant, voir memEncoderNow) et écrit.
func histTxPutJSON(b *bolt.Bucket, key string, v any, encode func([]byte) ([]byte, error)) error {
raw, err := json.Marshal(v)
if err != nil {
return err
}
enc, err := encode(raw)
if err != nil {
return err
}
return b.Put([]byte(key), enc)
}
// histPost ajoute sid à la liste du token (ensemble trié, idempotent).
func histPost(b *bolt.Bucket, tok, sid string, encode func([]byte) ([]byte, error)) error {
var ids []string
_ = histTxGetJSON(b, histPostKey(tok), &ids)
i := sort.SearchStrings(ids, sid)
if i < len(ids) && ids[i] == sid {
return nil
}
ids = append(ids, "")
copy(ids[i+1:], ids[i:])
ids[i] = sid
return histTxPutJSON(b, histPostKey(tok), ids, encode)
}
// histUnpost retire sid de la liste du token ; la clé disparaît si elle se vide.
func histUnpost(b *bolt.Bucket, tok, sid string, encode func([]byte) ([]byte, error)) error {
var ids []string
if !histTxGetJSON(b, histPostKey(tok), &ids) {
return nil
}
out := make([]string, 0, len(ids))
for _, x := range ids {
if x != sid {
out = append(out, x)
}
}
if len(out) == len(ids) {
return nil
}
if len(out) == 0 {
return b.Delete([]byte(histPostKey(tok)))
}
return histTxPutJSON(b, histPostKey(tok), out, encode)
}
// --- maintenance : indexation / désindexation ---------------------------------
// histIndexAt (ré)indexe une discussion dans la base path, en UNE transaction :
// mots retirés depuis la dernière fois, fiche, mots ajoutés. Une fiche d'une
// autre version (ou absente) fait tout reposer. Les sémantiques d'ensemble
// rendent l'opération idempotente. Mémoire chiffrée mais verrouillée : erreur,
// rien n'est écrit en clair (le rattrapage reprendra au déverrouillage).
func histIndexAt(path, id string, log []LogEvent) error {
if id == "" {
return nil
}
encode, err := memEncoderNow() // HORS transaction (voir memEncoderNow)
if err != nil {
return err
}
toks := histTokens(histVisibleText(log))
doc := histSearchDoc{V: histSearchIndexVersion, At: time.Now().Unix(),
Apercu: recallSnippet(histFirstVisible(log), 200)}
cacheBust(bkChatSearch)
return withDBAt(path, func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(bkChatSearch))
if err != nil {
return err
}
var prevDoc histSearchDoc
var prev []string
full := !histTxGetJSON(b, histDocKey(id), &prevDoc) || prevDoc.V != histSearchIndexVersion
_ = histTxGetJSON(b, histToksKey(id), &prev)
had := make(map[string]bool, len(prev))
for _, t := range prev {
had[t] = true
}
now := make(map[string]bool, len(toks))
for _, t := range toks {
now[t] = true
}
for _, t := range prev {
if !now[t] {
if err := histUnpost(b, t, id, encode); err != nil {
return err
}
}
}
for _, t := range toks {
if full || !had[t] {
if err := histPost(b, t, id, encode); err != nil {
return err
}
}
}
if err := histTxPutJSON(b, histToksKey(id), toks, encode); err != nil {
return err
}
return histTxPutJSON(b, histDocKey(id), doc, encode)
})
})
}
// histUnindexAt retire une discussion de l'index. Si sa liste de mots est
// lisible, ses postings partent avec ; sinon (verrouillé) seules ses clés
// « d| » et « t| » sont supprimées, en laissant des identifiants ORPHELINS
// dans les postings — tolérés : la recherche écarte tout identifiant absent de
// l'index des discussions.
func histUnindexAt(path, id string) {
if id == "" {
return
}
encode, err := memEncoderNow()
cacheBust(bkChatSearch)
_ = withDBAt(path, func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bkChatSearch))
if b == nil {
return nil
}
var prev []string
if err == nil && histTxGetJSON(b, histToksKey(id), &prev) {
for _, t := range prev {
if err := histUnpost(b, t, id, encode); err != nil {
return err
}
}
}
if err := b.Delete([]byte(histToksKey(id))); err != nil {
return err
}
return b.Delete([]byte(histDocKey(id)))
})
})
}
// histIndexWritten indexe les instantanés que l'écrivain vient de poser
// (chat_persist.go). Appelé DANS la boucle de l'écrivain, avant qu'elle ne
// marque le lot comme traité : un flush attend donc aussi l'index — c'est ce
// qui garantit qu'une suppression (forget, flush, puis histUnindexAt) passe
// toujours après la dernière indexation de la discussion.
func histIndexWritten(snaps []*convSnap) {
for _, s := range snaps {
if log, ok := histLogOf(s.body); ok {
_ = histIndexAt(s.path, s.id, log) // erreur avalée : le rattrapage reprendra
}
}
}
// histIndexStoreWrite suit les écritures de la façade dans /internal/v1/store :
// une discussion écrite (bkChat/conv:<id>) est réindexée, une discussion
// effacée (DELETE, ou valeur vide) est retirée. Le contenu arrive scellé :
// decodeMemContent l'ouvre, la clé ne quitte pas ce process. Synchrone : la
// façade n'envoie la suppression d'une discussion qu'après la réponse à son
// écriture, l'ordre est donc tenu sans file.
func histIndexStoreWrite(bucket, key string, val []byte, deleted bool) {
if bucket != bkChat || !strings.HasPrefix(key, "conv:") {
return
}
id := strings.TrimPrefix(key, "conv:")
if deleted || len(val) == 0 {
histUnindexAt(dbPath(), id)
return
}
plain, err := decodeMemContent(val)
if err != nil {
return
}
if log, ok := histLogOf(plain); ok {
_ = histIndexAt(dbPath(), id, log)
}
}
// --- rattrapage ---------------------------------------------------------------
var histBackfillRunning atomic.Bool
// startHistSearchBackfill lance le rattrapage en tâche de fond, au démarrage
// (après un délai : laisser moteur et lien démarrer d'abord) et au
// déverrouillage de la mémoire. Un seul à la fois.
func startHistSearchBackfill() { startHistSearchBackfillAfter(20 * time.Second) }
func startHistSearchBackfillAfter(d time.Duration) {
if !histBackfillRunning.CompareAndSwap(false, true) {
return
}
path := dbPath()
go func() {
defer histBackfillRunning.Store(false)
time.Sleep(d)
// La base visée au lancement, et elle seule : sous les tests, LOKI_HOME
// change d'un test à l'autre — on ne rattrape pas dans la base d'un autre,
// ni ne recrée celle d'un test terminé.
if _, err := os.Stat(path); err != nil || dbPath() != path {
return
}
histSearchIndexBackfill()
}()
}
// histNeedsIndex : la discussion manque-t-elle à l'index, ou y est-elle plus
// ancienne que sa dernière écriture (une indexation ratée après l'écriture) ?
func histNeedsIndex(docs map[string]histSearchDoc, id string, updated int64) bool {
d, ok := docs[id]
return !ok || d.V != histSearchIndexVersion || d.At < updated
}
// histReadDocs lit toutes les fiches « d| » lisibles (petites : version, date,
// aperçu).
func histReadDocs() map[string]histSearchDoc {
out := map[string]histSearchDoc{}
_ = view(bkChatSearch, func(b *bolt.Bucket) error {
c := b.Cursor()
pfx := []byte("d|")
for k, v := c.Seek(pfx); k != nil && bytes.HasPrefix(k, pfx); k, v = c.Next() {
plain, err := decodeMemContent(v)
if err != nil {
continue
}
var d histSearchDoc
if json.Unmarshal(plain, &d) == nil {
out[string(k[len(pfx):])] = d
}
}
return nil
})
return out
}
// histConvIDs : identifiants des discussions qui ont un CORPS en base (clés
// « conv:<id> » de bkChat), sans lire les valeurs.
func histConvIDs() map[string]bool {
out := map[string]bool{}
_ = view(bkChat, func(b *bolt.Bucket) error {
c := b.Cursor()
pfx := []byte("conv:")
for k, _ := c.Seek(pfx); k != nil && bytes.HasPrefix(k, pfx); k, _ = c.Next() {
out[string(k[len(pfx):])] = true
}
return nil
})
return out
}
// histSearchIndexBackfill indexe les discussions absentes de l'index, ou dont
// la fiche est plus ancienne que leur dernière écriture. S'arrête net
// verrouillé (corps illisibles) : il reprendra au déverrouillage.
func histSearchIndexBackfill() {
if memLocked() {
return
}
updated := map[string]int64{}
for _, m := range convIndex() {
updated[m.ID] = m.Updated
}
docs := histReadDocs()
ids := make([]string, 0)
for id := range histConvIDs() {
if histNeedsIndex(docs, id, updated[id]) {
ids = append(ids, id)
}
}
sort.Strings(ids)
for _, id := range ids {
b, err := getStoreBytesErr(bkChat, convKey(id))
if err != nil {
if memLocked() {
return
}
continue // illisible pour une autre raison (abîmée) : on saute
}
log, ok := histLogOf(b)
if !ok {
continue
}
if err := histIndexAt(dbPath(), id, log); err != nil {
return
}
time.Sleep(30 * time.Millisecond) // ne pas monopoliser la base
}
}
// --- recherche ----------------------------------------------------------------
// histSearchProject : le projet dont la barre latérale montre les discussions.
// Lu en base et non dans le cache de ce process : quand la façade tient la
// conversation, c'est elle qui change de projet.
func histSearchProject() string {
if s := strings.TrimSpace(getStr(bkState, stActiveProjectKey)); s != "" {
return s
}
return defaultProjectSlug
}
// histSearch est le cœur de la recherche, sur les discussions du projet
// `project` ("" = projet actif). Pertinence d'abord (score = 2 × termes du titre
// + termes du texte, même ratio que recall_search), puis plus récente, puis
// identifiant. offset/limit découpent APRÈS le tri ; limit <= 0 = tout.
func histSearch(q, project string, offset, limit int) histSearchResult {
res := histSearchResult{State: "ok", Hits: make([]histSearchHit, 0), Terms: make([]string, 0)}
terms := histQueryTerms(q)
if len(terms) == 0 {
res.State = "no_terms"
return res
}
// Verrouillé décidé AVANT toute lecture : « illisible » et « absent » se
// confondent au fond. Pour une recherche, un vide serait un mensonge.
if memLocked() {
res.State = "locked"
return res
}
res.Terms = terms
if project == "" {
project = histSearchProject()
}
metaByID := map[string]convMeta{}
for _, m := range convIndex() {
if convProjectOf(m) == project {
metaByID[m.ID] = m
}
}
// Discussion ouverte : quand CE process la tient, son journal en RAM est
// plus frais que sa dernière écriture (un tour en cours). Copié sous c.mu
// puis relâché : on tokenise hors verrou, jamais en bloquant une génération.
// Quand la façade la tient, le journal d'ici est périmé : l'index (alimenté
// par ses écritures) fait foi.
var liveID string
var liveLog []LogEvent
if !facadeChat() {
liveID = convActiveCached()
conv.mu.Lock()
liveLog = append([]LogEvent(nil), conv.Log...)
conv.mu.Unlock()
if _, ok := metaByID[liveID]; !ok || len(liveLog) == 0 {
liveID, liveLog = "", nil
}
}
titleHits := map[string]int{}
for id, m := range metaByID {
set := histTokenSet(m.Title)
n := 0
for _, t := range terms {
if histSetMatches(set, t) {
n++
}
}
if n > 0 {
titleHits[id] = n
}
}
// UNE vue sur l'index : postings des termes (scan PRÉFIXE : les clés sont
// triées, toutes celles qui commencent par « p|<terme> » se suivent) et
// fiches des discussions du projet (couverture, aperçu). Aucun corps lu.
bodyHits := map[string]int{}
docs := map[string]histSearchDoc{}
_ = view(bkChatSearch, func(b *bolt.Bucket) error {
for _, t := range terms {
prefix := []byte(histPostKey(t))
matched := map[string]bool{} // un terme ne compte qu'UNE fois par discussion
c := b.Cursor()
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
plain, err := decodeMemContent(v)
if err != nil {
continue
}
var ids []string
if json.Unmarshal(plain, &ids) != nil {
continue
}
for _, sid := range ids {
matched[sid] = true
}
}
for sid := range matched {
bodyHits[sid]++
}
}
for id := range metaByID {
var d histSearchDoc
if histTxGetJSON(b, histDocKey(id), &d) && d.V == histSearchIndexVersion {
docs[id] = d
}
}
return nil
})
// Discussion ouverte : son journal en RAM REMPLACE sa copie indexée (et non
// +=) — une seule entrée par identifiant, jamais de double compte.
if liveID != "" {
set := histTokenSet(histVisibleText(liveLog))
n := 0
for _, t := range terms {
if histSetMatches(set, t) {
n++
}
}
bodyHits[liveID] = n
}
score := func(id string) int { return 2*titleHits[id] + bodyHits[id] }
hits := make([]histSearchHit, 0)
for id, m := range metaByID {
if sc := score(id); sc > 0 {
hits = append(hits, histSearchHit{ID: id, Title: m.Title, Created: m.Created,
Updated: m.Updated, Turns: m.Turns, Score: sc, InTitle: titleHits[id] > 0})
}
}
// Les identifiants des postings absents de l'index des discussions (supprimées
// verrouillé, ou d'un autre projet) ne sont jamais parcourus : seul metaByID l'est.
sort.Slice(hits, func(i, j int) bool {
if hits[i].Score != hits[j].Score {
return hits[i].Score > hits[j].Score
}
if hits[i].Updated != hits[j].Updated {
return hits[i].Updated > hits[j].Updated
}
return hits[i].ID < hits[j].ID
})
res.Total = len(hits)
if limit > 0 {
off := max(0, min(offset, len(hits)))
hits = hits[off:min(off+limit, len(hits))]
}
histFillSnippets(hits, terms, docs, liveID, liveLog)
res.Hits = hits
// Couverture : le dénominateur est l'ensemble des discussions RÉELLEMENT
// indexables du projet — une entrée d'index sans corps en base (discussion
// toute neuve, jamais écrite) ne le sera jamais, la compter laisserait
// l'UI annoncer « indexation en cours » pour toujours (constat amont).
// Invariant : Indexed <= IndexedTotal.
bodies := histConvIDs()
for id, m := range metaByID {
if !bodies[id] && id != liveID {
continue
}
res.IndexedTotal++
if id == liveID {
res.Indexed++
continue
}
if !histNeedsIndex(docs, id, m.Updated) {
res.Indexed++
}
}
return res
}
// histFillSnippets pose l'extrait de chaque résultat de la PAGE : le passage
// autour du premier mot trouvé, relu dans le corps (une seule ouverture de la
// base pour toute la page) ; à défaut — corps illisible, mot trouvé seulement
// dans le titre — l'aperçu de la fiche. Le classement, lui, ne dépend jamais
// des corps.
func histFillSnippets(hits []histSearchHit, terms []string, docs map[string]histSearchDoc, liveID string, liveLog []LogEvent) {
if len(hits) == 0 {
return
}
texts := map[string]string{}
if liveID != "" {
texts[liveID] = histVisibleText(liveLog)
}
_ = view(bkChat, func(b *bolt.Bucket) error {
for _, h := range hits {
if _, ok := texts[h.ID]; ok {
continue
}
raw := b.Get([]byte(convKey(h.ID)))
if raw == nil {
continue
}
plain, err := decodeMemContent(raw)
if err != nil {
continue
}
if log, ok := histLogOf(plain); ok {
texts[h.ID] = histVisibleText(log)
}
}
return nil
})
for i := range hits {
s := ""
if txt, ok := texts[hits[i].ID]; ok {
s = histContextSnippet(txt, terms)
}
if s == "" {
s = docs[hits[i].ID].Apercu
}
if s == "" && liveID == hits[i].ID {
s = recallSnippet(histFirstVisible(liveLog), 200)
}
hits[i].Snippet = s
}
}
// handleConvSearch : GET /api/conversations/search?q=&offset=&limit=.
// Discussions du projet actif dont le titre OU le texte contient les mots
// cherchés (préfixes), avec un extrait. Toujours servie par ce process, même
// quand la façade tient la conversation (voir le commentaire de tête).
func handleConvSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "GET attendu"})
return
}
q := r.URL.Query()
if utf8.RuneCountInString(q.Get("q")) > 500 {
sendJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "requête trop longue"})
return
}
offset, _ := strconv.Atoi(q.Get("offset"))
limit, err := strconv.Atoi(q.Get("limit"))
if err != nil || limit <= 0 {
limit = histSearchDefaultLimit
}
limit = min(limit, histSearchMaxLimit)
res := histSearch(q.Get("q"), "", max(0, offset), limit)
// Index incomplet (discussions d'avant la fonctionnalité, indexation ratée) :
// on relance le rattrapage plutôt que d'attendre le prochain démarrage. L'UI
// annonce la couverture partielle en attendant.
if res.State == "ok" && res.Indexed < res.IndexedTotal {
startHistSearchBackfillAfter(time.Second)
}
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "state": res.State, "terms": res.Terms,
"conversations": res.Hits, "total": res.Total,
"indexed": res.Indexed, "indexed_total": res.IndexedTotal})
}
-728
View File
@@ -1,728 +0,0 @@
package loki
// chat_search_test.go — recherche plein-texte dans les discussions. Repris
// d'AJEAN (chat_search_test.go, commit 558d3b4d, issue amont #98) et adaptés au
// modèle de Loki : pas d'archives à part, toutes les discussions vivent dans
// bkChat/conv:<id> et l'index suit chaque écriture de l'écrivain différé
// (chat_persist.go) — ou de la façade, par /internal/v1/store. S'y ajoutent les
// cas propres à Loki : écritures de la façade, extrait en contexte, mise à jour
// incrémentale, index périmé.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// --- aides --------------------------------------------------------------------
// histSetup isole $LOKI_HOME et neutralise la discussion ouverte du process :
// chaque test pose explicitement celle qu'il veut (histSetLive).
func histSetup(t *testing.T) {
t.Helper()
testHome(t)
clearMemDEK()
conv.mu.Lock()
prevLog := conv.Log
conv.Log = nil
conv.mu.Unlock()
convActiveCache.Store(nil)
t.Cleanup(func() {
conv.mu.Lock()
conv.Log = prevLog
conv.mu.Unlock()
convActiveCache.Store(nil)
clearMemDEK()
})
}
func histUser(ts int64, s string) LogEvent {
return LogEvent{TS: ts, Delta: map[string]any{"user": s}}
}
func histContent(ts int64, s string) LogEvent {
return LogEvent{TS: ts, Delta: map[string]any{"content": s}}
}
func histBody(t *testing.T, log []LogEvent) []byte {
t.Helper()
b, err := json.Marshal(map[string]any{"messages": []Message{}, "log": log})
if err != nil {
t.Fatal(err)
}
return b
}
// histConv écrit une discussion PAR LE CHEMIN DE PRODUCTION (l'écrivain
// différé, qui pose contenu, entrée d'index et index plein-texte), puis fixe
// son titre et sa date dans l'index des discussions.
func histConv(t *testing.T, id, project, title string, updated int64, log []LogEvent) {
t.Helper()
if project == "" {
project = defaultProjectSlug
}
turns := 0
for _, ev := range log {
if _, ok := ev.Delta["user"]; ok {
turns++
}
}
persistQ.wait(persistQ.enqueueSnap(&convSnap{path: dbPath(), id: id, seq: persistSeq.Add(1),
body: histBody(t, log), title: title, turns: turns, project: project}))
histSetMeta(t, convMeta{ID: id, Title: title, Created: updated, Updated: updated, Turns: turns, Project: project})
}
// histSetMeta pose (ou remplace) l'entrée d'une discussion dans l'index des
// discussions, sans toucher à son contenu.
func histSetMeta(t *testing.T, m convMeta) {
t.Helper()
convIndexMu.Lock()
defer convIndexMu.Unlock()
idx := convIndex()
for i := range idx {
if idx[i].ID == m.ID {
idx[i] = m
convIndexSave(idx)
return
}
}
convIndexSave(append(idx, m))
}
// histSetLive fait de id la discussion ouverte, avec ce journal en RAM.
func histSetLive(t *testing.T, id string, log []LogEvent) {
t.Helper()
convActiveRemember(id)
conv.mu.Lock()
conv.Log = append([]LogEvent(nil), log...)
conv.mu.Unlock()
}
func histHasHit(res histSearchResult, id string) bool {
for _, h := range res.Hits {
if h.ID == id {
return true
}
}
return false
}
func histHitIDs(res histSearchResult) string {
var ids []string
for _, h := range res.Hits {
ids = append(ids, h.ID)
}
return strings.Join(ids, ",")
}
func histDocExists(t *testing.T, id string) bool {
t.Helper()
_, ok := getStoreBytes(bkChatSearch, histDocKey(id))
return ok
}
func histPosting(t *testing.T, tok string) []string {
t.Helper()
b, ok := getStoreBytes(bkChatSearch, histPostKey(tok))
if !ok {
return nil
}
var ids []string
if err := json.Unmarshal(b, &ids); err != nil {
t.Fatalf("posting %q illisible : %v", tok, err)
}
return ids
}
// --- 1. texte trouvé même si le titre ne contient rien ------------------------
func TestHistSearchBodyMatch(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Voyage en Bretagne", 100, []LogEvent{histUser(1, "parlons du phare de Brest")})
histConv(t, "B", "", "Recette", 200, []LogEvent{histUser(1, "préparons une tarte")})
res := histSearch("phare", "", 0, 0)
if res.State != "ok" {
t.Fatalf("état attendu ok, obtenu %q", res.State)
}
if res.Total != 1 || len(res.Hits) != 1 || res.Hits[0].ID != "A" {
t.Fatalf("A attendu seul, obtenu total=%d %+v", res.Total, res.Hits)
}
if res.Hits[0].Snippet == "" {
t.Fatal("extrait attendu non vide pour A")
}
if res.Hits[0].InTitle {
t.Fatal("A est trouvée par son texte, pas par son titre")
}
}
// --- 2. un mot du titre pèse plus qu'un mot du texte --------------------------
// Le titre compte double (+2/+1, comme recall_search). B est plus RÉCENTE que
// A : un tri par date seule inverserait l'ordre.
func TestHistSearchTitleBeatsBody(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Le phare de Brest", 100, []LogEvent{histUser(1, "rien de spécial")})
histConv(t, "B", "", "Discussion diverse", 200, []LogEvent{histUser(1, "on a vu un phare hier")})
res := histSearch("phare", "", 0, 0)
if got := histHitIDs(res); got != "A,B" {
t.Fatalf("ordre attendu A,B, obtenu %s", got)
}
if !res.Hits[0].InTitle || res.Hits[1].InTitle {
t.Fatalf("in_title attendu vrai pour A seulement : %+v", res.Hits)
}
}
// --- 3. pertinence, puis date, puis identifiant -------------------------------
func TestHistSearchRelevanceThenDate(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Sans rapport", 200, []LogEvent{histUser(1, "un phare dans le texte")})
histConv(t, "B", "", "Phare ancien", 100, []LogEvent{histUser(1, "texte neutre")})
histConv(t, "C", "", "Phare récent", 300, []LogEvent{histUser(1, "texte neutre")})
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "C,B,A" {
t.Fatalf("ordre attendu C,B,A, obtenu %s", got)
}
}
// --- 4. la discussion OUVERTE est cherchée dans sa version en RAM -------------
// En plein tour, le journal en mémoire est plus frais que la dernière écriture :
// le mot vient d'arriver, il n'est pas encore dans l'index.
func TestHistSearchIncludesActiveSession(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Autre sujet", 100, []LogEvent{histUser(1, "on parle boussole")})
histConv(t, "live1", "", "salut", 200, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "salut"), histContent(2, "le phare en direct")})
res := histSearch("phare", "", 0, 0)
if res.Total != 1 || len(res.Hits) != 1 || res.Hits[0].ID != "live1" {
t.Fatalf("la discussion ouverte devait ressortir seule : %+v", res.Hits)
}
if !strings.Contains(res.Hits[0].Snippet, "phare") {
t.Fatalf("extrait tiré du journal en RAM attendu, obtenu %q", res.Hits[0].Snippet)
}
}
// --- 5. chercher n'écrit RIEN -------------------------------------------------
func TestHistSearchDoesNotWrite(t *testing.T) {
histSetup(t)
histConv(t, "live1", "", "salut", 100, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "le phare en direct")})
beforeChat, beforeIdx := allKV(bkChat), allKV(bkChatSearch)
_ = histSearch("phare", "", 0, 0)
if after := allKV(bkChat); len(after) != len(beforeChat) || after[convKey("live1")] != beforeChat[convKey("live1")] || after[ckIndex] != beforeChat[ckIndex] {
t.Fatal("la recherche a réécrit une discussion ou l'index des discussions")
}
after := allKV(bkChatSearch)
if len(after) != len(beforeIdx) {
t.Fatalf("la recherche a modifié l'index plein-texte (%d → %d clés)", len(beforeIdx), len(after))
}
for k, v := range beforeIdx {
if after[k] != v {
t.Fatalf("la recherche a réécrit %q", k)
}
}
}
// --- 6. cloisonnement par projet ----------------------------------------------
func TestHistSearchProjectScope(t *testing.T) {
histSetup(t)
histConv(t, "A", "alpha", "Projet alpha", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "B", "beta", "Projet beta", 100, []LogEvent{histUser(1, "le phare")})
if got := histHitIDs(histSearch("phare", "alpha", 0, 0)); got != "A" {
t.Fatalf("projet alpha : A attendu seul, obtenu %s", got)
}
if got := histHitIDs(histSearch("phare", "beta", 0, 0)); got != "B" {
t.Fatalf("projet beta : B attendu seul, obtenu %s", got)
}
// Sans projet donné : celui de la barre latérale, lu en base.
if err := setActiveProject("beta"); err != nil {
t.Fatal(err)
}
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "B" {
t.Fatalf("projet actif beta : B attendu seul, obtenu %s", got)
}
}
// --- 7. mémoire verrouillée = état explicite, pas « rien trouvé » -------------
func TestHistSearchLockedState(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Secret", 100, []LogEvent{histUser(1, "le phare secret")})
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
clearMemDEK()
res := histSearch("phare", "", 0, 0)
if res.State != "locked" {
t.Fatalf("état attendu locked, obtenu %q", res.State)
}
if len(res.Hits) != 0 || res.Total != 0 {
t.Fatalf("aucun résultat attendu verrouillé, obtenu %+v", res.Hits)
}
}
// --- 8. requête sans terme exploitable ----------------------------------------
func TestHistSearchNoUsableTerms(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Phare", 100, []LogEvent{histUser(1, "le phare")})
res := histSearch("a!", "", 0, 0)
if res.State != "no_terms" {
t.Fatalf("état attendu no_terms, obtenu %q", res.State)
}
if res.Terms == nil || len(res.Terms) != 0 || res.Total != 0 {
t.Fatalf("aucun terme (tableau vide, jamais null) ni résultat attendu : terms=%#v total=%d", res.Terms, res.Total)
}
}
// --- 9. cycle de vie : écriture puis suppression de la discussion -------------
func TestHistSearchIndexLifecycle(t *testing.T) {
histSetup(t)
histConv(t, "lifeA", "", "A", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "lifeB", "", "B", 200, []LogEvent{histUser(1, "le phare")})
if !histDocExists(t, "lifeA") || !histDocExists(t, "lifeB") {
t.Fatal("les deux fiches d| devaient exister après l'écriture")
}
if ids := histPosting(t, "phare"); len(ids) != 2 {
t.Fatalf("posting phare attendu de 2, obtenu %v", ids)
}
if err := convDelete("lifeA"); err != nil {
t.Fatal(err)
}
if histDocExists(t, "lifeA") {
t.Fatal("d|lifeA devait disparaître avec la discussion")
}
if _, ok := getStoreBytes(bkChatSearch, histToksKey("lifeA")); ok {
t.Fatal("t|lifeA devait disparaître avec la discussion")
}
if ids := histPosting(t, "phare"); len(ids) != 1 || ids[0] != "lifeB" {
t.Fatalf("posting phare attendu [lifeB], obtenu %v", ids)
}
}
// --- 9 bis. mise à jour incrémentale ------------------------------------------
// Réécrire une discussion ne touche que les mots qui ont changé — et un mot
// disparu (journal compacté, discussion vidée) quitte bien l'index.
func TestHistSearchIncrementalUpdate(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "phare boussole")})
if ids := histPosting(t, "boussole"); len(ids) != 1 {
t.Fatalf("boussole attendu indexé, obtenu %v", ids)
}
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "phare compas")})
if ids := histPosting(t, "boussole"); len(ids) != 0 {
t.Fatalf("boussole devait quitter l'index, obtenu %v", ids)
}
if ids := histPosting(t, "compas"); len(ids) != 1 || ids[0] != "A" {
t.Fatalf("compas attendu [A], obtenu %v", ids)
}
if ids := histPosting(t, "phare"); len(ids) != 1 {
t.Fatalf("phare attendu une fois, obtenu %v", ids)
}
}
// --- 10. l'index est chiffré ----------------------------------------------------
func TestHistSearchBucketEncrypted(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Titre secret", 100, []LogEvent{histUser(1, "le phare")})
if raw := getBytes(bkChatSearch, histDocKey("A")); looksEncrypted(raw) {
t.Fatal("avant activation, la valeur ne devait pas être chiffrée")
}
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
for _, k := range []string{histDocKey("A"), histToksKey("A"), histPostKey("phare")} {
if !looksEncrypted(getBytes(bkChatSearch, k)) {
t.Fatalf("%s devait être chiffré (bucket absent d'encryptedBuckets ?)", k)
}
}
// Écrite après l'activation : chiffrée d'emblée, et toujours trouvable.
histConv(t, "B", "", "Autre", 200, []LogEvent{histUser(1, "le phare encore")})
if !looksEncrypted(getBytes(bkChatSearch, histPostKey("encore"))) {
t.Fatal("posting écrit mémoire chiffrée resté en clair")
}
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "B,A" {
t.Fatalf("B,A attendus mémoire déverrouillée, obtenu %s", got)
}
clearMemDEK()
if _, ok := getStoreBytes(bkChatSearch, histDocKey("A")); ok {
t.Fatal("index relisible alors que la mémoire est verrouillée")
}
}
// --- 11. couverture : jamais présenter un index partiel comme complet ---------
func TestHistSearchCoverageContract(t *testing.T) {
histSetup(t)
log := []LogEvent{histUser(1, "le phare")}
// Écrites À LA MAIN, sans passer par l'index : des discussions d'avant la
// fonctionnalité.
for _, id := range []string{"a", "b", "c"} {
if err := putStoreBytes(bkChat, convKey(id), histBody(t, log)); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: id, Title: id, Updated: 100, Project: defaultProjectSlug})
}
if err := histIndexAt(dbPath(), "a", log); err != nil {
t.Fatal(err)
}
res := histSearch("phare", "", 0, 0)
if res.IndexedTotal != 3 || res.Indexed != 1 {
t.Fatalf("couverture attendue 1/3, obtenue %d/%d", res.Indexed, res.IndexedTotal)
}
if res.Total != 1 {
t.Fatalf("1 seule trouvable avant rattrapage, obtenu %d", res.Total)
}
histSearchIndexBackfill()
res = histSearch("phare", "", 0, 0)
if res.Indexed != 3 || res.IndexedTotal != 3 || res.Total != 3 {
t.Fatalf("après rattrapage : %d/%d, %d trouvées (3 attendues)", res.Indexed, res.IndexedTotal, res.Total)
}
}
// --- 11 bis. une fiche plus ancienne que la discussion est à refaire ----------
// L'écriture a réussi, l'indexation qui suit a raté (base occupée, process
// coupé) : la fiche existe mais décrit un état antérieur. Elle ne doit pas
// compter comme à jour, et le rattrapage doit la refaire.
func TestHistSearchStaleDocReindexed(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "le phare")})
future := histReadDocs()["A"].At + 10
if err := putStoreBytes(bkChat, convKey("A"), histBody(t, []LogEvent{histUser(1, "le phare"), histContent(2, "une boussole")})); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: "A", Title: "Neutre", Updated: future, Project: defaultProjectSlug})
res := histSearch("boussole", "", 0, 0)
if res.Total != 0 || res.Indexed != 0 || res.IndexedTotal != 1 {
t.Fatalf("fiche périmée : 0 trouvée et couverture 0/1 attendues, obtenu %d trouvée(s), %d/%d", res.Total, res.Indexed, res.IndexedTotal)
}
histSearchIndexBackfill()
// Le rattrapage date la fiche de MAINTENANT : on recale la discussion dessus.
histSetMeta(t, convMeta{ID: "A", Title: "Neutre", Updated: histReadDocs()["A"].At, Project: defaultProjectSlug})
res = histSearch("boussole", "", 0, 0)
if res.Total != 1 || res.Indexed != 1 {
t.Fatalf("après rattrapage : boussole attendue trouvée et index à jour, obtenu %d trouvée(s), %d/%d", res.Total, res.Indexed, res.IndexedTotal)
}
}
// --- 12. le classement vient de l'index, pas des corps ------------------------
// Corps disparu, fiche et postings restés : la discussion reste trouvable, et
// l'extrait se rabat sur l'aperçu de la fiche.
func TestHistSearchRanksFromIndexNotBodies(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Titre", 100, []LogEvent{histUser(1, "le phare")})
if err := putBytes(bkChat, convKey("A"), nil); err != nil {
t.Fatal(err)
}
res := histSearch("phare", "", 0, 0)
if res.Total != 1 || res.Hits[0].ID != "A" {
t.Fatalf("le résultat devait venir de l'index, obtenu %+v", res.Hits)
}
if res.Hits[0].Snippet != "le phare" {
t.Fatalf("aperçu de la fiche attendu, obtenu %q", res.Hits[0].Snippet)
}
}
// --- 13. la discussion ouverte n'apparaît qu'une fois, son score n'est pas SOMMÉ
// Ouverte ET indexée : une seule entrée, et le journal en RAM REMPLACE la copie
// indexée au lieu de s'y ajouter. Requête « alpha beta », A ouverte :
//
// A : titre « alpha » (1) ; texte « alpha beta » (2) → 2*1+2 = 4 ; sommé → 6.
// B : titre « alpha beta » (2) ; texte « alpha » (1) → 2*2+1 = 5.
//
// Remplacement : [B A]. Somme : [A B] — l'ordre s'inverse.
func TestHistSearchDeduplicatesActiveAgainstIndex(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "alpha", 100, []LogEvent{histUser(1, "alpha beta")})
histConv(t, "B", "", "alpha beta", 200, []LogEvent{histUser(1, "alpha")})
histSetLive(t, "A", []LogEvent{histUser(1, "alpha beta")})
res := histSearch("alpha beta", "", 0, 0)
if got := histHitIDs(res); got != "B,A" {
t.Fatalf("une entrée par discussion, score remplacé : B,A attendus, obtenu %s (%+v)", got, res.Hits)
}
}
// --- 14. pagination : total AVANT découpage -----------------------------------
func TestHistSearchPagination(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Phare A", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "B", "", "Phare B", 200, []LogEvent{histUser(1, "le phare")})
histConv(t, "C", "", "Phare C", 300, []LogEvent{histUser(1, "le phare")})
page := histSearch("phare", "", 1, 1)
if len(page.Hits) != 1 || page.Total != 3 || page.Hits[0].ID != "B" {
t.Fatalf("page 2 de 1 : B et total 3 attendus, obtenu %+v total=%d", page.Hits, page.Total)
}
beyond := histSearch("phare", "", 10, 5)
if len(beyond.Hits) != 0 || beyond.Total != 3 {
t.Fatalf("au-delà de la fin : 0 résultat et total 3 attendus, obtenu %d / %d", len(beyond.Hits), beyond.Total)
}
}
// --- 15. repli des accents, dans les DEUX sens --------------------------------
func TestHistSearchAccentFolding(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Notes", 100, []LogEvent{histUser(1, "voici le résumé du rapport")})
histConv(t, "B", "", "Notes", 200, []LogEvent{histUser(1, "voici le resume du dossier")})
if n := len(histSearch("resume", "", 0, 0).Hits); n != 2 {
t.Fatalf("resume devait trouver les 2, obtenu %d", n)
}
if n := len(histSearch("RÉSUMÉ", "", 0, 0).Hits); n != 2 {
t.Fatalf("RÉSUMÉ devait trouver les 2, obtenu %d", n)
}
}
// --- 16. seul le texte VISIBLE est indexé -------------------------------------
func TestHistSearchIndexesVisibleTextOnly(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histContent(1, "voici un zèbre dans la réponse")})
histConv(t, "B", "", "Neutre", 200, []LogEvent{{TS: 1, Delta: map[string]any{"tool_used": map[string]any{
"name": "bash", "label": "rg zèbre", "result": "peu importe", "done": true}}}})
histConv(t, "C", "", "Neutre", 300, []LogEvent{{TS: 1, Delta: map[string]any{"reasoning_content": "je pense au zèbre"}}})
histConv(t, "D", "", "Neutre", 400, []LogEvent{{TS: 1, Delta: map[string]any{"tool_used": map[string]any{
"name": "bash", "label": "ls", "result": "le zèbre est dans la sortie", "done": true}}}})
res := histSearch("zèbre", "", 0, 0)
if !histHasHit(res, "A") || !histHasHit(res, "B") {
t.Fatalf("réponse et libellé d'outil devaient être indexés : %+v", res.Hits)
}
if histHasHit(res, "C") || histHasHit(res, "D") {
t.Fatalf("raisonnement et sortie d'outil ne devaient PAS être indexés : %+v", res.Hits)
}
}
// --- 17. correspondance par PRÉFIXE -------------------------------------------
func TestHistSearchPrefixMatchesBody(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Sujet neutre", 100, []LogEvent{histUser(1, "on passe du temps à compiling le binaire")})
histConv(t, "B", "", "Autre", 200, []LogEvent{histUser(1, "rien à voir ici")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "A" {
t.Fatalf("compil devait trouver A (« compiling »), obtenu %s", got)
}
}
// Garde-fous inverses : un préfixe qui ne commence aucun mot ne rend rien, et le
// MILIEU d'un mot ne correspond pas. « deepseek » est dans un TITRE à dessein :
// le scan des postings, borné au préfixe « p|eepseek », ne peut pas atteindre
// « deepseek » ; seul le titre verrait une bascule HasPrefix → Contains.
func TestHistSearchPrefixRejectsNonPrefix(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "compiling", 100, []LogEvent{histUser(1, "compiling le binaire")})
histConv(t, "B", "", "deepseek", 200, []LogEvent{histUser(1, "on parle deepseek ici")})
for _, q := range []string{"compilo", "eepseek"} {
if res := histSearch(q, "", 0, 0); res.Total != 0 {
t.Fatalf("%q ne devait rien trouver, obtenu %s", q, histHitIDs(res))
}
}
}
func TestHistSearchPrefixMatchesTitle(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Compilation annuelle", 100, []LogEvent{histUser(1, "texte neutre")})
histConv(t, "B", "", "Sujet divers", 200, []LogEvent{histUser(1, "on finit le compiling")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "A,B" {
t.Fatalf("le titre (+2) doit primer le texte (+1) : A,B attendus, obtenu %s", got)
}
}
// Un préfixe qui touche plusieurs mots d'une même discussion ne compte qu'une
// fois : à un incrément par mot, A (3 variantes, plus ancienne) passerait devant.
func TestHistSearchPrefixCountsTermOnce(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "compiling compilation compiled")})
histConv(t, "B", "", "Neutre", 200, []LogEvent{histUser(1, "on a fini de compiling")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "B,A" {
t.Fatalf("score par TERME (égalité, B plus récente) : B,A attendus, obtenu %s", got)
}
}
// La discussion ouverte suit la même règle de préfixe que l'index.
func TestHistSearchPrefixMatchesLiveSession(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Autre", 100, []LogEvent{histUser(1, "rien de spécial")})
histConv(t, "live1", "", "salut", 200, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "salut"), histContent(2, "on parle de compilation ici")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "live1" {
t.Fatalf("la discussion ouverte devait ressortir sur « compil », obtenu %s", got)
}
}
// --- 18. couverture : une entrée d'index SANS corps n'est pas « à indexer » ---
// Une discussion toute neuve est dans l'index des discussions avant d'avoir
// jamais été écrite : elle ne recevra pas de fiche tant qu'elle est vide. La
// compter au dénominateur laissait l'UI annoncer « indexation en cours » pour
// toujours (constat amont, 13/14 stable).
func TestHistSearchCoverageIgnoresMetaWithoutBody(t *testing.T) {
histSetup(t)
histConv(t, "normale", "", "Sujet", 100, []LogEvent{histUser(1, "le phare")})
histSetMeta(t, convMeta{ID: "neuve", Title: "", Updated: 200, Project: defaultProjectSlug})
res := histSearch("phare", "", 0, 0)
if res.IndexedTotal != 1 || res.Indexed != 1 {
t.Fatalf("couverture 1/1 attendue, obtenue %d/%d", res.Indexed, res.IndexedTotal)
}
if !histHasHit(res, "normale") || histHasHit(res, "neuve") {
t.Fatalf("la discussion écrite attendue, la neuve exclue : %+v", res.Hits)
}
}
// --- 19. l'extrait montre le passage trouvé, pas le début -----------------------
// Le début d'une discussion est son titre (convSummary) : l'aperçu seul
// n'apprendrait rien. L'extrait est pris autour du mot trouvé, loin dans le fil.
func TestHistSearchSnippetInContext(t *testing.T) {
histSetup(t)
long := strings.Repeat("du texte sans rapport ", 40)
histConv(t, "A", "", "Question de départ", 100, []LogEvent{
histUser(1, "Question de départ"),
histContent(2, long+"et voici enfin le Phare du Créac'h, au bout de l'île. "+long),
})
res := histSearch("phare", "", 0, 0)
if len(res.Hits) != 1 {
t.Fatalf("A attendue, obtenu %+v", res.Hits)
}
sn := res.Hits[0].Snippet
if !strings.Contains(sn, "Phare du Créac'h") {
t.Fatalf("extrait autour du mot attendu, obtenu %q", sn)
}
if !strings.HasPrefix(sn, "…") || !strings.HasSuffix(sn, "…") {
t.Fatalf("extrait coupé des deux côtés attendu (…), obtenu %q", sn)
}
if n := len([]rune(sn)); n > 260 {
t.Fatalf("extrait trop long (%d runes) : %q", n, sn)
}
}
// --- 20. écritures de la FAÇADE (LOKI_CHAT=rust) ------------------------------
// La façade écrit ses discussions par /internal/v1/store, scellées par ce
// process : l'index doit les suivre — écriture, réécriture, suppression — sans
// que la façade ait rien à en savoir.
func TestHistSearchFacadeStoreWrites(t *testing.T) {
histSetup(t)
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: "r1", Title: "Écrite par la façade", Updated: 100, Project: defaultProjectSlug})
put := func(log []LogEvent) {
t.Helper()
sealed, err := encodeMemContent(histBody(t, log))
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/internal/v1/store?bucket=chat&key=conv:r1", strings.NewReader(string(sealed)))
handleInternalStore(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("PUT : %d %s", rr.Code, rr.Body.String())
}
}
put([]LogEvent{histUser(1, "le phare vu par la façade")})
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "r1" {
t.Fatalf("discussion de la façade attendue, obtenu %q", got)
}
if !looksEncrypted(getBytes(bkChatSearch, histPostKey("phare"))) {
t.Fatal("index tenu pour la façade resté en clair")
}
put([]LogEvent{histUser(1, "maintenant une boussole")})
if res := histSearch("phare", "", 0, 0); res.Total != 0 {
t.Fatalf("réécrite sans « phare » : plus rien attendu, obtenu %s", histHitIDs(res))
}
rr := httptest.NewRecorder()
handleInternalStore(rr, httptest.NewRequest(http.MethodDelete, "/internal/v1/store?bucket=chat&key=conv:r1", nil))
if rr.Code != http.StatusNoContent {
t.Fatalf("DELETE : %d", rr.Code)
}
if histDocExists(t, "r1") || len(histPosting(t, "boussole")) != 0 {
t.Fatal("discussion supprimée par la façade restée dans l'index")
}
// Une autre clé du même bucket (l'index des discussions) n'est pas une discussion.
histIndexStoreWrite(bkChat, ckIndex, []byte("[]"), false)
if histDocExists(t, "index") {
t.Fatal("l'index des discussions a été pris pour une discussion")
}
}
// La route reste à ce process : la façade ne la sert pas (elle la relaie), et
// le tunnel du relais ne la lui remet pas.
func TestHistSearchRouteNotDelegated(t *testing.T) {
if facadeChatRoutes["/api/conversations/search"] {
t.Fatal("/api/conversations/search ne doit pas être remise à la façade : l'index est tenu ici")
}
}
// --- 21. la route HTTP --------------------------------------------------------
func TestHandleConvSearch(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Le phare", 100, []LogEvent{histUser(1, "Le phare")})
rr := httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodGet, "/api/conversations/search?q=phare", nil))
var got struct {
OK bool `json:"ok"`
State string `json:"state"`
Conversations []histSearchHit `json:"conversations"`
Total int `json:"total"`
Indexed int `json:"indexed"`
IndexedTotal int `json:"indexed_total"`
Terms []string `json:"terms"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("réponse illisible : %v (%s)", err, rr.Body.String())
}
if !got.OK || got.State != "ok" || got.Total != 1 || len(got.Conversations) != 1 || !got.Conversations[0].InTitle {
t.Fatalf("réponse inattendue : %+v", got)
}
if got.Indexed != 1 || got.IndexedTotal != 1 || strings.Join(got.Terms, ",") != "phare" {
t.Fatalf("couverture ou termes inattendus : %+v", got)
}
rr = httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodGet, "/api/conversations/search?q=", nil))
if !strings.Contains(rr.Body.String(), `"conversations":[]`) || !strings.Contains(rr.Body.String(), `"no_terms"`) {
t.Fatalf("requête vide : tableau vide et no_terms attendus, obtenu %s", rr.Body.String())
}
rr = httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodPost, "/api/conversations/search", nil))
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST : 405 attendu, obtenu %d", rr.Code)
}
}
-1
View File
@@ -330,7 +330,6 @@ func convDelete(id string) error {
convIndexSave(next)
convIndexMu.Unlock()
_ = putBytes(bkChat, convKey(id), nil)
histUnindexAt(dbPath(), id) // son index plein-texte part avec elle (chat_search.go)
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
// elle, et ses critères/mode/puce partent avec ses messages.
stopConvJobs(id)
+7 -3
View File
@@ -37,11 +37,15 @@ var facadeMCPRoutes = map[string]bool{
"/api/mcp/toggle": true,
"/api/mcp/tool": true,
"/api/mcp/test": true,
// Les délais globaux : la façade tient le pool, c'est elle qui doit
// fermer ses sessions quand ils changent.
"/api/mcp/timeouts": true,
"/api/mcp/timeouts/save": true,
}
// facadeMCPJSON appelle l'API interne MCP de la façade et décode sa réponse.
// Délai large : un appel d'outil MCP peut durer (mcpCallTimeout), une
// première connexion npx/uvx aussi (mcpConnectTimeoutCold).
// Délai large, simple garde-fou : la façade borne elle-même la connexion
// (1 h au plus) et l'appel, nouvel essai compris (mcpCallTimeoutMax chacun).
func facadeMCPJSON(path string, body any, out any) error {
b, err := json.Marshal(body)
if err != nil {
@@ -53,7 +57,7 @@ func facadeMCPJSON(path string, body any, out any) error {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := (&http.Client{Timeout: mcpConnectTimeoutCold + mcpCallTimeout}).Do(req)
resp, err := (&http.Client{Timeout: mcpTimeoutMaxSec*time.Second + 2*mcpCallTimeoutMax}).Do(req)
if err != nil {
return err
}
+14 -9
View File
@@ -74,22 +74,27 @@ func TestMCPCallHonorsTurnCancel(t *testing.T) {
}
func TestMCPCallTimeoutFor(t *testing.T) {
def := MCPTimeouts{}.normalized()
cases := []struct {
cfg MCPServerConfig
t MCPTimeouts
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
{MCPServerConfig{}, def, nil, 120 * time.Second},
{MCPServerConfig{CallTimeoutSec: 600}, def, nil, 10 * time.Minute},
{MCPServerConfig{}, def, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes
{MCPServerConfig{}, def, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes
{MCPServerConfig{}, def, map[string]any{"timeout": 5000.0}, 120 * time.Second}, // 5 s : le défaut reste
{MCPServerConfig{}, def, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne
{MCPServerConfig{}, def, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond
{MCPServerConfig{CallTimeoutSec: 30}, def, nil, 30 * time.Second}, // plus court que le défaut
{MCPServerConfig{}, MCPTimeouts{Connect: 20, Call: 45}, nil, 45 * time.Second}, // réglage global
{MCPServerConfig{CallTimeoutSec: 90}, MCPTimeouts{Connect: 20, Call: 45}, nil, 90 * time.Second},
{MCPServerConfig{}, MCPTimeouts{Connect: 20, Call: 3600}, nil, time.Hour}, // pas rogné par le plafond
}
for i, c := range cases {
if got := mcpCallTimeoutFor(c.cfg, c.args); got != c.want {
if got := mcpCallTimeoutFor(c.cfg, c.t, c.args); got != c.want {
t.Errorf("cas %d : %s, attendu %s", i, got, c.want)
}
}
+38 -26
View File
@@ -36,19 +36,17 @@ import (
// pour router les appels sans avoir à re-parser des noms potentiellement
// ambigus.
// Les délais de connexion et d'appel sont réglables (mcp_timeouts.go) :
// 20 s et 120 s par défaut.
const (
// mcpConnectTimeout borne l'établissement d'une session (handshake initialize
// + tools/list). Un serveur qui rame ne doit pas figer le tour de chat.
mcpConnectTimeout = 20 * time.Second
// mcpConnectTimeoutCold : npx et uvx TÉLÉCHARGENT le paquet du serveur au
// premier lancement (le catalogue les utilise tous les deux). Sur un réseau
// ou un disque lents, ça dépasse largement 20 s, et l'utilisateur voyait
// « enregistré mais connexion échouée : context deadline exceeded » pour un
// serveur parfaitement sain. Les lancements suivants profitent du cache et
// retombent dans le régime normal.
// C'est un minimum : un délai de connexion réglé plus long l'emporte.
mcpConnectTimeoutCold = 3 * time.Minute
// mcpCallTimeout borne un appel d'outil MCP.
mcpCallTimeout = 120 * time.Second
)
// mcpOutputCap borne la sortie d'un outil MCP renvoyée au modèle. L'amont
@@ -194,11 +192,12 @@ func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession {
m.mu.Unlock()
// Connexion hors-lock (peut être lente).
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg))
timeouts := LoadMCPTimeouts()
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg, timeouts))
defer cancel()
sess, err := mcpConnect(ctx, name, cfg)
if err != nil {
s.err = mcpFriendlyErr(err, cfg)
s.err = mcpFriendlyErr(err, cfg, timeouts)
} else {
s.sess = sess
lt, lerr := sess.ListTools(ctx, nil)
@@ -391,7 +390,7 @@ func mcpCall(parent context.Context, name string, args map[string]any) string {
}
servers, _ := LoadMCPConfig()
cfg, exists := servers[ref.server]
timeout := mcpCallTimeoutFor(cfg, args)
timeout := mcpCallTimeoutFor(cfg, LoadMCPTimeouts(), args)
result, err := mcpCallOnce(parent, ref, args, timeout)
if err != nil {
@@ -424,14 +423,16 @@ func mcpCall(parent context.Context, name string, args map[string]any) string {
// 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
// Il suit la borne des réglages (1 h) : un délai réglé à 3600 s n'est pas
// rogné en silence.
const mcpCallTimeoutMax = mcpTimeoutMaxSec * time.Second
// mcpCallTimeoutFor : délai d'un appel. Défaut mcpCallTimeout, remplacé par
// CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même
// mcpCallTimeoutFor : délai d'un appel. Le réglage global (t.Call), 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
func mcpCallTimeoutFor(cfg MCPServerConfig, t MCPTimeouts, args map[string]any) time.Duration {
d := t.call()
if cfg.CallTimeoutSec > 0 {
d = time.Duration(cfg.CallTimeoutSec) * time.Second
}
@@ -598,6 +599,8 @@ type MCPServerStatus struct {
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
// CallTimeoutSec : délai d'un appel propre au serveur (0 = réglage global).
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
}
// MCPStatus renvoie l'état de tous les serveurs configurés, en tentant de
@@ -621,7 +624,9 @@ func MCPStatus() ([]MCPServerStatus, error) {
URL: cfg.URL,
Headers: cfg.Headers,
Tools: []string{},
Disabled: cfg.DisabledTools,
CallTimeoutSec: cfg.CallTimeoutSec,
Disabled: cfg.DisabledTools,
}
if st.Disabled == nil {
st.Disabled = []string{}
@@ -643,29 +648,36 @@ func MCPStatus() ([]MCPServerStatus, error) {
return out, nil
}
// mcpTimeoutFor choisit le délai de connexion d'un serveur : long pour un
// lanceur qui télécharge son paquet à la volée (npx, uvx), court sinon.
func mcpTimeoutFor(cfg MCPServerConfig) time.Duration {
// mcpTimeoutFor choisit le délai de connexion d'un serveur : le réglage
// global, mais au moins mcpConnectTimeoutCold pour un lanceur qui télécharge
// son paquet à la volée (npx, uvx).
func mcpTimeoutFor(cfg MCPServerConfig, t MCPTimeouts) time.Duration {
if mcpColdLauncher(cfg) {
return max(t.connect(), mcpConnectTimeoutCold)
}
return t.connect()
}
// mcpColdLauncher : npx ou uvx, qui téléchargent le paquet au premier
// lancement.
func mcpColdLauncher(cfg MCPServerConfig) bool {
base := strings.ToLower(filepath.Base(cfg.Command))
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
if base == "npx" || base == "uvx" {
return mcpConnectTimeoutCold
}
return mcpConnectTimeout
return base == "npx" || base == "uvx"
}
// mcpFriendlyErr habille l'erreur de connexion la plus vécue : le délai
// dépassé d'un npx/uvx en plein téléchargement. L'erreur brute (« context
// deadline exceeded ») envoyait l'utilisateur chercher un bug de config alors
// qu'il suffit souvent de réessayer une fois le paquet en cache.
func mcpFriendlyErr(err error, cfg MCPServerConfig) error {
func mcpFriendlyErr(err error, cfg MCPServerConfig, t MCPTimeouts) error {
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
return err
}
base := strings.ToLower(filepath.Base(cfg.Command))
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
if base == "npx" || base == "uvx" {
if mcpColdLauncher(cfg) {
base := strings.ToLower(filepath.Base(cfg.Command))
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
return fmt.Errorf("délai dépassé — %s télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", base)
}
return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s", mcpConnectTimeout)
return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s (délai de connexion réglable dans les réglages MCP)", t.connect())
}
+1 -1
View File
@@ -52,7 +52,7 @@ type MCPServerConfig struct {
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
// 0 = réglage global (MCPTimeouts.Call). Pour un serveur dont les outils sont longs
// par nature (build, scraping, requête lourde).
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
}
+89
View File
@@ -0,0 +1,89 @@
package loki
import (
"time"
)
// Délais MCP réglables depuis l'UI (repris d'AJEAN c05a0a9f) : connexion d'une
// session et appel d'outil, en secondes, communs à tous les serveurs. Un
// serveur peut remplacer le délai d'appel par le sien (CallTimeoutSec), et un
// lancement npx/uvx garde au moins mcpConnectTimeoutCold pour se connecter.
// MCPTimeouts : les délais globaux, en secondes. Stockés dans bkState sous
// la clé mcpTimeoutsKey ; zéro (ou clé absente) = valeur par défaut.
type MCPTimeouts struct {
// Connect borne l'établissement d'une session (handshake initialize +
// tools/list). Un serveur qui rame ne doit pas figer le tour de chat.
Connect int `json:"connect"`
// Call borne un appel d'outil MCP.
Call int `json:"call"`
}
const (
// Valeurs par défaut, en secondes (les anciennes constantes).
mcpConnectTimeoutDefault = 20
mcpCallTimeoutDefault = 120
// mcpTimeoutMaxSec : borne haute des réglages (1 h), globaux comme par
// serveur.
mcpTimeoutMaxSec = 3600
mcpTimeoutsKey = "mcp_timeouts"
)
// LoadMCPTimeouts lit les délais globaux. Une valeur absente, nulle ou
// illisible retombe sur son défaut ; une valeur hors bornes (base modifiée à
// la main) est ramenée à mcpTimeoutMaxSec.
func LoadMCPTimeouts() MCPTimeouts {
var t MCPTimeouts
if !getJSON(bkState, mcpTimeoutsKey, &t) {
t = MCPTimeouts{}
}
return t.normalized()
}
func (t MCPTimeouts) normalized() MCPTimeouts {
fix := func(v, def int) int {
switch {
case v <= 0:
return def
case v > mcpTimeoutMaxSec:
return mcpTimeoutMaxSec
}
return v
}
return MCPTimeouts{
Connect: fix(t.Connect, mcpConnectTimeoutDefault),
Call: fix(t.Call, mcpCallTimeoutDefault),
}
}
// SaveMCPTimeouts enregistre les délais globaux (valeurs déjà validées par
// l'appelant ; normalisées par sûreté).
func SaveMCPTimeouts(t MCPTimeouts) error {
mcpConfigMu.Lock()
defer mcpConfigMu.Unlock()
return putJSON(bkState, mcpTimeoutsKey, t.normalized())
}
// connect et call : les délais en durées.
func (t MCPTimeouts) connect() time.Duration { return time.Duration(t.Connect) * time.Second }
func (t MCPTimeouts) call() time.Duration { return time.Duration(t.Call) * time.Second }
// mcpTimeoutsError : le refus d'un réglage hors bornes, nil sinon. Même texte
// pour la façade (mcp/mod.rs).
func mcpTimeoutsError(t MCPTimeouts) string {
if t.Connect < 1 || t.Connect > mcpTimeoutMaxSec {
return "connexion : 1 à 3600 s"
}
if t.Call < 1 || t.Call > mcpTimeoutMaxSec {
return "appel d'outil : 1 à 3600 s"
}
return ""
}
// mcpCallTimeoutSecError : le refus d'un délai par serveur hors bornes.
func mcpCallTimeoutSecError(sec int) string {
if sec < 0 || sec > mcpTimeoutMaxSec {
return "délai d'un appel : 0 à 3600 s (vide = réglage global)"
}
return ""
}
+171
View File
@@ -0,0 +1,171 @@
package loki
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Une base vide donne les délais par défaut : 20 s de connexion, 120 s
// d'appel.
func TestMCPTimeoutsDefautsSansConfig(t *testing.T) {
testHome(t)
got := LoadMCPTimeouts()
if got != (MCPTimeouts{Connect: 20, Call: 120}) {
t.Fatalf("défauts = %+v", got)
}
if d := mcpTimeoutFor(MCPServerConfig{Command: "node"}, got); d != 20*time.Second {
t.Errorf("connexion = %s", d)
}
if d := mcpCallTimeoutFor(MCPServerConfig{}, got, nil); d != 120*time.Second {
t.Errorf("appel = %s", d)
}
}
// Enregistrés puis relus ; une valeur nulle retombe sur son défaut, une
// valeur trop grande (base modifiée à la main) sur la borne, une valeur
// illisible sur les défauts.
func TestMCPTimeoutsAllerRetour(t *testing.T) {
testHome(t)
if err := SaveMCPTimeouts(MCPTimeouts{Connect: 5, Call: 300}); err != nil {
t.Fatal(err)
}
if got := LoadMCPTimeouts(); got != (MCPTimeouts{Connect: 5, Call: 300}) {
t.Fatalf("relecture = %+v", got)
}
for raw, want := range map[string]MCPTimeouts{
`{"connect":0,"call":0}`: {20, 120},
`{"connect":-4,"call":99999}`: {20, 3600},
`{"connect":"x","call":50}`: {20, 120},
`{"CONNECT":7}`: {7, 120},
`null`: {20, 120},
`{"connect":30,"call":40} reste`: {20, 120},
} {
if err := putStr(bkState, mcpTimeoutsKey, raw); err != nil {
t.Fatal(err)
}
if got := LoadMCPTimeouts(); got != want {
t.Errorf("%s : %+v, attendu %+v", raw, got, want)
}
}
}
// Un lanceur npx/uvx garde au moins mcpConnectTimeoutCold ; un délai réglé
// plus long l'emporte.
func TestMCPTimeoutForLanceurFroid(t *testing.T) {
npx := MCPServerConfig{Command: "npx"}
if d := mcpTimeoutFor(npx, MCPTimeouts{Connect: 20, Call: 120}); d != mcpConnectTimeoutCold {
t.Errorf("npx, 20 s : %s", d)
}
if d := mcpTimeoutFor(npx, MCPTimeouts{Connect: 600, Call: 120}); d != 10*time.Minute {
t.Errorf("npx, 600 s : %s", d)
}
if d := mcpTimeoutFor(MCPServerConfig{URL: "http://x"}, MCPTimeouts{Connect: 45, Call: 120}); d != 45*time.Second {
t.Errorf("http, 45 s : %s", d)
}
}
func mcpRoute(t *testing.T, h func(w *httptest.ResponseRecorder), want int) map[string]any {
t.Helper()
w := httptest.NewRecorder()
h(w)
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse %q : %v", w.Body.String(), err)
}
if w.Code != want {
t.Fatalf("code %d, attendu %d : %s", w.Code, want, w.Body.String())
}
return out
}
// Les routes des délais globaux : lecture, refus hors bornes, enregistrement
// qui ferme les sessions ouvertes.
func TestMCPTimeoutsRoutes(t *testing.T) {
testHome(t)
t.Setenv(facadeMCPEnv, "")
get := func() map[string]any {
return mcpRoute(t, func(w *httptest.ResponseRecorder) {
handleMCPTimeouts(w, httptest.NewRequest("GET", "/api/mcp/timeouts", nil))
}, 200)
}
post := func(body string, want int) map[string]any {
return mcpRoute(t, func(w *httptest.ResponseRecorder) {
handleMCPTimeoutsSave(w, httptest.NewRequest("POST", "/api/mcp/timeouts/save", strings.NewReader(body)))
}, want)
}
if v := get(); v["connect"] != 20.0 || v["call"] != 120.0 {
t.Fatalf("GET : %v", v)
}
for body, msg := range map[string]string{
`{"connect":0,"call":120}`: "connexion : 1 à 3600 s",
`{"connect":20,"call":3601}`: "appel d'outil : 1 à 3600 s",
`{"connect":20}`: "appel d'outil : 1 à 3600 s",
`{"connect":"20","call":120}`: "json: cannot unmarshal string into Go struct field MCPTimeouts.connect of type int",
} {
if v := post(body, 400); v["error"] != msg {
t.Errorf("%s : %v", body, v["error"])
}
}
// Une session ouverte sur un serveur configuré : fermée à l'enregistrement.
if err := SetMCPServer("lent", MCPServerConfig{URL: "http://127.0.0.1:1/mcp", Enabled: false}); err != nil {
t.Fatal(err)
}
fakeMCPSlow(t)
if v := post(`{"connect":30,"call":600}`, 200); v["connect"] != 30.0 || v["call"] != 600.0 {
t.Fatalf("POST : %v", v)
}
mcpMgr.mu.Lock()
_, open := mcpMgr.sessions["lent"]
mcpMgr.mu.Unlock()
if open {
t.Error("la session est restée ouverte")
}
if v := get(); v["connect"] != 30.0 || v["call"] != 600.0 {
t.Fatalf("GET après enregistrement : %v", v)
}
if got := getStr(bkState, mcpTimeoutsKey); got != `{"connect":30,"call":600}` {
t.Errorf("stocké : %s", got)
}
}
// Le délai d'un serveur passe par /api/mcp/save, revient dans /api/mcp et
// survit à un nouvel enregistrement sans lui (0 = réglage global).
func TestMCPSaveCallTimeoutSec(t *testing.T) {
testHome(t)
t.Setenv(facadeMCPEnv, "")
t.Cleanup(mcpCloseAll)
save := func(body string, want int) map[string]any {
return mcpRoute(t, func(w *httptest.ResponseRecorder) {
handleMCPSave(w, httptest.NewRequest("POST", "/api/mcp/save", strings.NewReader(body)))
}, want)
}
v := save(`{"name":"long","url":"http://127.0.0.1:1/mcp","enabled":false,"callTimeoutSec":900}`, 200)
srv := v["servers"].([]any)[0].(map[string]any)
if srv["callTimeoutSec"] != 900.0 {
t.Fatalf("état : %v", srv)
}
servers, _ := LoadMCPConfig()
if servers["long"].CallTimeoutSec != 900 {
t.Fatalf("config : %+v", servers["long"])
}
st, _ := MCPStatus()
if len(st) != 1 || st[0].CallTimeoutSec != 900 {
t.Fatalf("MCPStatus : %+v", st)
}
// Champ vidé dans l'UI : retour au réglage global, clé absente.
v = save(`{"name":"long","url":"http://127.0.0.1:1/mcp","enabled":false}`, 200)
if _, ok := v["servers"].([]any)[0].(map[string]any)["callTimeoutSec"]; ok {
t.Fatalf("callTimeoutSec resté : %v", v)
}
for _, body := range []string{
`{"name":"x","url":"http://h","callTimeoutSec":-1}`,
`{"name":"x","url":"http://h","callTimeoutSec":3601}`,
} {
if v := save(body, 400); v["error"] != "délai d'un appel : 0 à 3600 s (vide = réglage global)" {
t.Errorf("%s : %v", body, v["error"])
}
}
}
+3 -4
View File
@@ -163,15 +163,14 @@ func bucketFullyEncrypted(bucket string) bool {
// chiffrement est actif. Ici : les discussions (bkChat porte l'index, la
// discussion active et le journal de chacune), les blocs archivés au compactage
// (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et
// les trackers (bkTracker, 3ᵉ type de mémoire), les résultats complets des
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil) et l'index
// plein-texte des discussions (bkChatSearch — leurs mots, chat_search.go).
// les trackers (bkTracker, 3ᵉ type de mémoire), et les résultats complets des
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil).
//
// Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils
// portent des réglages, pas des données personnelles — et surtout le coffre
// lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une
// boucle : plus moyen de déverrouiller quoi que ce soit.
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes, bkChatSearch}
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes}
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
func reencryptChatStores() error {
-3
View File
@@ -79,9 +79,6 @@ func memUnlocked() bool {
return memDEK != nil
}
// memLocked : mémoire chiffrée MAIS verrouillée (DEK absente).
func memLocked() bool { return memEncActive() && !memUnlocked() }
// setMemDEK charge la DEK en RAM (copie défensive).
func setMemDEK(dek []byte) {
memKeyMu.Lock()
+68 -5
View File
@@ -91,13 +91,76 @@ func TestPariteMCP(t *testing.T) {
}
fx["prompt_line"] = lines
timeouts := map[string]any{}
def := MCPTimeouts{}.normalized()
for _, cmd := range []string{"npx", "/usr/local/bin/NPX", "uvx.exe", "npx.cmd", "node", "", "uvx-x", "C:/bin/uvx.EXE"} {
cfg := MCPServerConfig{Command: cmd}
timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg).Seconds()),
"err": mcpFriendlyErr(context.DeadlineExceeded, cfg).Error()}
timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg, def).Seconds()),
"err": mcpFriendlyErr(context.DeadlineExceeded, cfg, def).Error()}
}
fx["timeouts"] = timeouts
fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}).Error()
fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}, def).Error()
// Délais globaux (mcp_timeouts) : lecture de la base, puis leur effet sur
// la connexion et sur l'appel.
var stored []any
for _, raw := range []string{"", `{"connect":5,"call":300}`, `{"connect":0,"call":0}`, `{"connect":-4,"call":99999}`,
`{"connect":"x","call":50}`, `{"CONNECT":7,"Call":8}`, `null`, `[1]`, `{"connect":30,"call":40} reste`,
`{"connect":1.5,"call":60}`, `{"connect":3600,"call":3600,"autre":1}`} {
_ = putStr(bkState, mcpTimeoutsKey, raw)
stored = append(stored, map[string]any{"in": raw, "out": LoadMCPTimeouts()})
}
fx["timeouts_stored"] = stored
var effects []any
for _, tm := range []MCPTimeouts{{20, 120}, {5, 45}, {600, 3600}, {180, 1}} {
for _, c := range []struct {
cfg MCPServerConfig
args string
}{
{MCPServerConfig{Command: "node"}, `{}`},
{MCPServerConfig{Command: "npx"}, `{}`},
{MCPServerConfig{URL: "http://h", CallTimeoutSec: 90}, `{}`},
{MCPServerConfig{Command: "uvx", CallTimeoutSec: 3600}, `{"timeout":7200}`},
{MCPServerConfig{URL: "http://h"}, `{"timeout":600}`},
{MCPServerConfig{URL: "http://h"}, `{"timeout":99999999}`},
} {
var args map[string]any
_ = json.Unmarshal([]byte(c.args), &args)
cfgJSON, _ := json.Marshal(c.cfg)
effects = append(effects, map[string]any{
"timeouts": tm, "cfg": string(cfgJSON), "args": c.args,
"connect": mcpTimeoutFor(c.cfg, tm).Milliseconds(),
"err": mcpFriendlyErr(context.DeadlineExceeded, c.cfg, tm).Error(),
"call": mcpCallTimeoutFor(c.cfg, tm, args).Milliseconds(),
})
}
}
fx["timeouts_effects"] = effects
_ = putStr(bkState, mcpTimeoutsKey, "")
var routes []any
for _, rq := range [][2]string{
{"/api/mcp/timeouts", ""},
{"/api/mcp/timeouts/save", `{"connect":0,"call":120}`},
{"/api/mcp/timeouts/save", `{"connect":20,"call":3601}`},
{"/api/mcp/timeouts/save", `{"connect":20}`},
{"/api/mcp/timeouts/save", `{"connect":"20","call":120}`},
{"/api/mcp/timeouts/save", `{"connect":2.5,"call":120}`},
{"/api/mcp/timeouts/save", `[]`},
{"/api/mcp/timeouts/save", `pas du json`},
{"/api/mcp/timeouts/save", `{"Connect":45,"CALL":900,"x":1}`},
{"/api/mcp/timeouts", ""},
{"/api/mcp/timeouts/save", `{"connect":3600,"call":1}`},
{"/api/mcp/timeouts", ""},
} {
w := httptest.NewRecorder()
if rq[0] == "/api/mcp/timeouts" {
handleMCPTimeouts(w, httptest.NewRequest("GET", rq[0], nil))
} else {
handleMCPTimeoutsSave(w, httptest.NewRequest("POST", rq[0], strings.NewReader(rq[1])))
}
routes = append(routes, map[string]any{"path": rq[0], "body": rq[1], "code": w.Code,
"resp": strings.TrimSpace(w.Body.String()), "state": getStr(bkState, mcpTimeoutsKey)})
}
fx["timeouts_routes"] = routes
_ = putStr(bkState, mcpTimeoutsKey, "")
var flat []any
for _, raw := range []string{
`{"content":[{"type":"text","text":" un "},{"type":"text","text":"deux\n"}]}`,
@@ -148,7 +211,7 @@ func TestPariteMCP(t *testing.T) {
{Op: "set", Name: "a__b", Cfg: `{"command":"x"}`},
{Op: "set", Name: "both", Cfg: `{"command":"x","url":"http://u"}`},
{Op: "set", Name: "none", Cfg: `{"enabled":true}`},
{Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false}`},
{Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false,"callTimeoutSec":45}`},
{Op: "enable", Name: "web", On: true},
{Op: "enable", Name: "nope", On: true},
{Op: "tool", Name: "fs", Tool: "write", On: false},
@@ -285,7 +348,7 @@ func TestPariteMCP(t *testing.T) {
}
_ = SetMCPToolEnabled("alpha", "secret", false)
_ = SetMCPServer("beta", MCPServerConfig{Command: "loki-mcp-absent-pour-test", Enabled: true})
_ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false})
_ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false, CallTimeoutSec: 300})
raw := getStr(bkState, "mcp")
raw = strings.Replace(raw, `}}`, `},"delta":{"enabled":true}}`, 1)
_ = putStr(bkState, "mcp", raw)
-5
View File
@@ -42,11 +42,6 @@ const (
// bkTracker : les trackers, 3e type de mémoire (tracker.go). Clé
// « <slug-projet>/<slug-tracker> » — le cloisonnement par projet est dans la clé.
bkTracker = "tracker"
// bkChatSearch : index PLEIN-TEXTE des discussions (chat_search.go) — fiche
// et liste de mots par discussion, liste de discussions par mot. Chiffré
// comme bkChat : un sac de mots en clair à côté de discussions chiffrées
// serait une fuite.
bkChatSearch = "chatsearch"
)
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
+76 -91
View File
@@ -1797,14 +1797,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
/* Extrait d'un résultat de recherche plein-texte : sous le titre, à la place
de .preset-meta, une seule ligne tronquée comme le titre. */
.side .preset .conv-snip{font-size:12px;color:var(--dim);margin-top:3px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
/* Ligne à extrait : deux lignes de texte. L'heure et l'anneau se calent sur
la ligne de base du TITRE au lieu de se centrer entre les deux (les boutons,
en position absolue, restent centrés sur la ligne entière). */
.side .preset.has-snip{align-items:baseline}
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
border:0;padding:8px 11px;text-align:left;cursor:pointer}
.conv-more:hover{color:var(--text)}
@@ -2359,12 +2351,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
actif est partagé par tous les appareils (comme la conversation unique
d'origine) : basculer ici bascule partout.
La recherche filtre d'abord les titres CÔTÉ CLIENT (la liste est déjà
chargée, réponse immédiate), puis demande au serveur les discussions dont
le TEXTE contient les mots (index plein-texte, chat_search.go) — avec un
extrait sous le titre. -->
La recherche filtre CÔTÉ CLIENT : la liste est déjà entièrement chargée
(métadonnées seules, sans les messages), un aller-retour serveur par
frappe n'apporterait rien. -->
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion" aria-label="nouvelle discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
<input id="conv-search" type="search" placeholder="rechercher partout…" oninput="renderConversations()" aria-label="rechercher dans les titres et le texte des discussions">
<input id="conv-search" type="search" placeholder="rechercher une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
<div id="conv-list" class="slist"></div>
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
@@ -2782,6 +2773,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div id="mcp-block" class="gated-group">
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div class="slist">
<!-- Délais communs à tous les serveurs (AJEAN c05a0a9f, e44b6139),
enregistrés à chaque modification. Un serveur peut remplacer le
délai d'appel dans sa fenêtre d'édition. -->
<div class="pe-row">
<span class="pe-row-l">Délai de connexion<span class="pe-sub">pour joindre un serveur ; au moins 3 min au premier lancement npx/uvx</span></span>
<span class="pe-row-c"><input id="mcp-timeout-connect" class="pe-val" type="number" min="1" max="3600" step="1" value="20" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Délai d'un appel d'outil<span class="pe-sub">réglable aussi serveur par serveur</span></span>
<span class="pe-row-c"><input id="mcp-timeout-call" class="pe-val" type="number" min="1" max="3600" step="1" value="120" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="rowbody">
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
@@ -3301,6 +3303,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div class="fld">
<label class="fld-l" for="mcp-call-timeout">Délai d'un appel d'outil <span class="opt">(secondes, 1 à 3600 — vide = réglage global)</span></label>
<input id="mcp-call-timeout" class="ctl" type="number" min="1" max="3600" step="1" inputmode="numeric" placeholder="réglage global">
</div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400;font-family:var(--font)"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
@@ -9511,7 +9517,47 @@ async function deleteExternal(){
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
async function loadMCP(){
renderMCP(await jget('/api/mcp'));
loadMcpTimeouts();
}
// ─── Délais globaux ──────────────────────────────────────────────────────────
// Connexion et appel d'outil, communs à tous les serveurs (/api/mcp/timeouts).
// Un seul enregistrement par modification : « change » part à la validation
// ou à la perte du focus, pas à chaque frappe.
let mcpTimeouts = {connect: 20, call: 120};
async function loadMcpTimeouts(){
const r = await jget('/api/mcp/timeouts');
if(!r || !r.ok) return;
mcpTimeouts = {connect: r.connect, call: r.call};
document.getElementById('mcp-timeout-connect').value = r.connect;
document.getElementById('mcp-timeout-call').value = r.call;
mcpCallTimeoutPlaceholder();
}
async function saveMcpTimeouts(){
const connect = Number(document.getElementById('mcp-timeout-connect').value);
const call = Number(document.getElementById('mcp-timeout-call').value);
const ok = v => Number.isInteger(v) && v >= 1 && v <= 3600;
if(!ok(connect) || !ok(call)){
toast('délai invalide : de 1 à 3600 s');
loadMcpTimeouts(); // revient aux valeurs enregistrées
return;
}
const r = await jpost('/api/mcp/timeouts/save', {connect, call});
if(!r || !r.ok){ toast('erreur : '+((r && r.error) || 'délais non enregistrés')); loadMcpTimeouts(); return; }
mcpTimeouts = {connect: r.connect, call: r.call};
mcpCallTimeoutPlaceholder();
toast('délais MCP enregistrés');
}
// Le champ « délai d'un appel » de la fenêtre d'édition montre le réglage
// global qu'il remplacerait.
function mcpCallTimeoutPlaceholder(){
document.getElementById('mcp-call-timeout').placeholder = 'réglage global ('+mcpTimeouts.call+' s)';
}
function renderMCP(r){
mcpServers = (r && r.servers) || [];
@@ -9684,6 +9730,7 @@ function openMcpFromCatalog(e){
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
document.getElementById('mcp-url').value = '';
document.getElementById('mcp-headers').value = '';
document.getElementById('mcp-call-timeout').value = '';
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
}
const st = document.getElementById('mcp-modal-status');
@@ -9711,6 +9758,8 @@ function openMcp(name){
document.getElementById('mcp-env').value = s && s.env ? Object.entries(s.env).map(([k,v])=>k+'='+v).join('\n') : '';
document.getElementById('mcp-url').value = s ? (s.url||'') : '';
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
document.getElementById('mcp-call-timeout').value = s && s.callTimeoutSec ? s.callTimeoutSec : '';
mcpCallTimeoutPlaceholder();
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠ '+s.error; st.style.color = 'var(--err)'; }
@@ -9789,6 +9838,13 @@ async function saveMcp(){
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
// Vide = réglage global (0 côté serveur).
const ct = document.getElementById('mcp-call-timeout').value.trim();
if(ct){
const n = Number(ct);
if(!Number.isInteger(n) || n < 1 || n > 3600){ toast("délai d'un appel d'outil : de 1 à 3600 s, ou vide"); return; }
body.callTimeoutSec = n;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
@@ -10537,11 +10593,8 @@ function fmtWhen(sec){
// balisage ni les gestionnaires d'événement.
//
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
// les TITRES sans aller-retour serveur : elle ne porte que des métadonnées —
// identifiant, titre, dates, nombre d'échanges — jamais les messages. Le TEXTE
// des discussions, lui, est cherché par le serveur (/api/conversations/search,
// index plein-texte chiffré comme les discussions) : sa réponse arrive après
// une courte pause de frappe et complète la liste, avec un extrait.
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
// titre, dates, nombre d'échanges — jamais les messages.
let CONVS = [], CONV_ACTIVE = '';
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
@@ -10555,65 +10608,11 @@ let CONV_BUSY = false;
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
// Recherche plein-texte : dernière réponse du serveur (pour la requête q), et
// minuterie de la pause de frappe. Une réponse arrivée pour une requête qui
// n'est plus celle du champ est jetée.
// convSrvGen numérote les requêtes : un rechargement de la liste en relance
// une pour la même saisie, la réponse d'avant ne doit plus s'afficher.
let CONV_SRV = null, convSrvTimer = 0, convSrvWanted = '', convSrvGen = 0;
const CONV_SRV_PAUSE = 250;
function convSearchSchedule(q){
if(q === convSrvWanted) return;
convSrvWanted = q;
clearTimeout(convSrvTimer);
const gen = ++convSrvGen;
if(!q) return;
convSrvTimer = setTimeout(async () => {
let r;
try{ r = await jget('/api/conversations/search?limit=100&q=' + encodeURIComponent(q)); }catch(_){ r = null; }
if(gen !== convSrvGen) return; // saisie changée, ou liste rechargée depuis
CONV_SRV = {q, r: (r && r.ok) ? r : null, failed: !(r && r.ok)};
renderConversations();
}, CONV_SRV_PAUSE);
}
// Liste affichée pour une requête : les résultats du serveur dans leur ordre
// (pertinence : titre puis texte), puis les titres trouvés ici que le serveur
// n'a pas (il cherche des débuts de mots, le filtre local une sous-chaîne).
// Rend aussi les extraits et la ligne d'état éventuelle.
function convSearchList(q){
const byTitle = CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q));
const snips = new Map();
const cur = CONV_SRV && CONV_SRV.q === q ? CONV_SRV : null;
const srv = cur ? cur.r : null;
if(!srv) return {list: byTitle, snips, note: cur && cur.failed ? 'recherche dans le texte indisponible : titres seulement' : ''};
let note = '';
const got = (srv.conversations || []).length;
if(srv.state === 'locked') note = 'mémoire verrouillée : recherche dans les titres seulement';
else if(srv.indexed < srv.indexed_total) note = 'indexation en cours (' + srv.indexed + '/' + srv.indexed_total + ') : résultats partiels';
else if(srv.total > got) note = 'les ' + got + ' plus pertinentes sur ' + srv.total + ' : précisez la recherche';
const byID = new Map(CONVS.map(c => [c.id, c]));
const list = [], seen = new Set();
for(const h of (srv.conversations || [])){
const c = byID.get(h.id);
if(!c || seen.has(c.id)) continue;
seen.add(c.id);
list.push(c);
if(h.snippet && h.snippet !== (c.title || '')) snips.set(c.id, h.snippet);
}
for(const c of byTitle) if(!seen.has(c.id)) list.push(c);
return {list, snips, note};
}
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// La liste a changé (fin de tour, bascule, suppression) : une recherche en
// cours est refaite. Ses résultats restent affichés d'ici la nouvelle
// réponse — les effacer ferait clignoter la liste à chaque tour.
convSrvWanted = '';
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
@@ -10632,14 +10631,9 @@ function renderConversations(){
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
convSearchSchedule(q);
const {list, snips, note} = q ? convSearchList(q) : {list: CONVS, snips: new Map(), note: ''};
if(note){
const e = document.createElement('div');
e.className = 'conv-empty';
e.textContent = note;
cont.appendChild(e);
}
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
if(!list.length){
const e = document.createElement('div');
e.className = 'conv-empty';
@@ -10654,8 +10648,7 @@ function renderConversations(){
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
const row = document.createElement('div');
const sn = snips.get(c.id);
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : '');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
// qui travaille sans redessiner la liste (un redessin relancerait
// l'animation de l'anneau à chaque événement du flux).
@@ -10668,14 +10661,6 @@ function renderConversations(){
nm.appendChild(document.createTextNode(title));
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
info.appendChild(nm);
// Recherche : le passage du texte où les mots ont été trouvés.
if(sn){
const s = document.createElement('div');
s.className = 'conv-snip';
s.textContent = sn;
s.title = sn;
info.appendChild(s);
}
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
+19 -5
View File
@@ -66,12 +66,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
actif est partagé par tous les appareils (comme la conversation unique
d'origine) : basculer ici bascule partout.
La recherche filtre d'abord les titres CÔTÉ CLIENT (la liste est déjà
chargée, réponse immédiate), puis demande au serveur les discussions dont
le TEXTE contient les mots (index plein-texte, chat_search.go) — avec un
extrait sous le titre. -->
La recherche filtre CÔTÉ CLIENT : la liste est déjà entièrement chargée
(métadonnées seules, sans les messages), un aller-retour serveur par
frappe n'apporterait rien. -->
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion" aria-label="nouvelle discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
<input id="conv-search" type="search" placeholder="rechercher partout…" oninput="renderConversations()" aria-label="rechercher dans les titres et le texte des discussions">
<input id="conv-search" type="search" placeholder="rechercher une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
<div id="conv-list" class="slist"></div>
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
@@ -489,6 +488,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="mcp-block" class="gated-group">
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div class="slist">
<!-- Délais communs à tous les serveurs (AJEAN c05a0a9f, e44b6139),
enregistrés à chaque modification. Un serveur peut remplacer le
délai d'appel dans sa fenêtre d'édition. -->
<div class="pe-row">
<span class="pe-row-l">Délai de connexion<span class="pe-sub">pour joindre un serveur ; au moins 3 min au premier lancement npx/uvx</span></span>
<span class="pe-row-c"><input id="mcp-timeout-connect" class="pe-val" type="number" min="1" max="3600" step="1" value="20" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Délai d'un appel d'outil<span class="pe-sub">réglable aussi serveur par serveur</span></span>
<span class="pe-row-c"><input id="mcp-timeout-call" class="pe-val" type="number" min="1" max="3600" step="1" value="120" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="rowbody">
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
@@ -1008,6 +1018,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div class="fld">
<label class="fld-l" for="mcp-call-timeout">Délai d'un appel d'outil <span class="opt">(secondes, 1 à 3600 — vide = réglage global)</span></label>
<input id="mcp-call-timeout" class="ctl" type="number" min="1" max="3600" step="1" inputmode="numeric" placeholder="réglage global">
</div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400;font-family:var(--font)"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
+51 -1
View File
@@ -5,7 +5,47 @@
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
async function loadMCP(){
renderMCP(await jget('/api/mcp'));
loadMcpTimeouts();
}
// ─── Délais globaux ──────────────────────────────────────────────────────────
// Connexion et appel d'outil, communs à tous les serveurs (/api/mcp/timeouts).
// Un seul enregistrement par modification : « change » part à la validation
// ou à la perte du focus, pas à chaque frappe.
let mcpTimeouts = {connect: 20, call: 120};
async function loadMcpTimeouts(){
const r = await jget('/api/mcp/timeouts');
if(!r || !r.ok) return;
mcpTimeouts = {connect: r.connect, call: r.call};
document.getElementById('mcp-timeout-connect').value = r.connect;
document.getElementById('mcp-timeout-call').value = r.call;
mcpCallTimeoutPlaceholder();
}
async function saveMcpTimeouts(){
const connect = Number(document.getElementById('mcp-timeout-connect').value);
const call = Number(document.getElementById('mcp-timeout-call').value);
const ok = v => Number.isInteger(v) && v >= 1 && v <= 3600;
if(!ok(connect) || !ok(call)){
toast('délai invalide : de 1 à 3600 s');
loadMcpTimeouts(); // revient aux valeurs enregistrées
return;
}
const r = await jpost('/api/mcp/timeouts/save', {connect, call});
if(!r || !r.ok){ toast('erreur : '+((r && r.error) || 'délais non enregistrés')); loadMcpTimeouts(); return; }
mcpTimeouts = {connect: r.connect, call: r.call};
mcpCallTimeoutPlaceholder();
toast('délais MCP enregistrés');
}
// Le champ « délai d'un appel » de la fenêtre d'édition montre le réglage
// global qu'il remplacerait.
function mcpCallTimeoutPlaceholder(){
document.getElementById('mcp-call-timeout').placeholder = 'réglage global ('+mcpTimeouts.call+' s)';
}
function renderMCP(r){
mcpServers = (r && r.servers) || [];
@@ -178,6 +218,7 @@ function openMcpFromCatalog(e){
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
document.getElementById('mcp-url').value = '';
document.getElementById('mcp-headers').value = '';
document.getElementById('mcp-call-timeout').value = '';
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
}
const st = document.getElementById('mcp-modal-status');
@@ -205,6 +246,8 @@ function openMcp(name){
document.getElementById('mcp-env').value = s && s.env ? Object.entries(s.env).map(([k,v])=>k+'='+v).join('\n') : '';
document.getElementById('mcp-url').value = s ? (s.url||'') : '';
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
document.getElementById('mcp-call-timeout').value = s && s.callTimeoutSec ? s.callTimeoutSec : '';
mcpCallTimeoutPlaceholder();
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠ '+s.error; st.style.color = 'var(--err)'; }
@@ -283,6 +326,13 @@ async function saveMcp(){
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
// Vide = réglage global (0 côté serveur).
const ct = document.getElementById('mcp-call-timeout').value.trim();
if(ct){
const n = Number(ct);
if(!Number.isInteger(n) || n < 1 || n > 3600){ toast("délai d'un appel d'outil : de 1 à 3600 s, ou vide"); return; }
body.callTimeoutSec = n;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
+6 -77
View File
@@ -10,11 +10,8 @@
// balisage ni les gestionnaires d'événement.
//
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
// les TITRES sans aller-retour serveur : elle ne porte que des métadonnées —
// identifiant, titre, dates, nombre d'échanges — jamais les messages. Le TEXTE
// des discussions, lui, est cherché par le serveur (/api/conversations/search,
// index plein-texte chiffré comme les discussions) : sa réponse arrive après
// une courte pause de frappe et complète la liste, avec un extrait.
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
// titre, dates, nombre d'échanges — jamais les messages.
let CONVS = [], CONV_ACTIVE = '';
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
@@ -28,65 +25,11 @@ let CONV_BUSY = false;
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
// Recherche plein-texte : dernière réponse du serveur (pour la requête q), et
// minuterie de la pause de frappe. Une réponse arrivée pour une requête qui
// n'est plus celle du champ est jetée.
// convSrvGen numérote les requêtes : un rechargement de la liste en relance
// une pour la même saisie, la réponse d'avant ne doit plus s'afficher.
let CONV_SRV = null, convSrvTimer = 0, convSrvWanted = '', convSrvGen = 0;
const CONV_SRV_PAUSE = 250;
function convSearchSchedule(q){
if(q === convSrvWanted) return;
convSrvWanted = q;
clearTimeout(convSrvTimer);
const gen = ++convSrvGen;
if(!q) return;
convSrvTimer = setTimeout(async () => {
let r;
try{ r = await jget('/api/conversations/search?limit=100&q=' + encodeURIComponent(q)); }catch(_){ r = null; }
if(gen !== convSrvGen) return; // saisie changée, ou liste rechargée depuis
CONV_SRV = {q, r: (r && r.ok) ? r : null, failed: !(r && r.ok)};
renderConversations();
}, CONV_SRV_PAUSE);
}
// Liste affichée pour une requête : les résultats du serveur dans leur ordre
// (pertinence : titre puis texte), puis les titres trouvés ici que le serveur
// n'a pas (il cherche des débuts de mots, le filtre local une sous-chaîne).
// Rend aussi les extraits et la ligne d'état éventuelle.
function convSearchList(q){
const byTitle = CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q));
const snips = new Map();
const cur = CONV_SRV && CONV_SRV.q === q ? CONV_SRV : null;
const srv = cur ? cur.r : null;
if(!srv) return {list: byTitle, snips, note: cur && cur.failed ? 'recherche dans le texte indisponible : titres seulement' : ''};
let note = '';
const got = (srv.conversations || []).length;
if(srv.state === 'locked') note = 'mémoire verrouillée : recherche dans les titres seulement';
else if(srv.indexed < srv.indexed_total) note = 'indexation en cours (' + srv.indexed + '/' + srv.indexed_total + ') : résultats partiels';
else if(srv.total > got) note = 'les ' + got + ' plus pertinentes sur ' + srv.total + ' : précisez la recherche';
const byID = new Map(CONVS.map(c => [c.id, c]));
const list = [], seen = new Set();
for(const h of (srv.conversations || [])){
const c = byID.get(h.id);
if(!c || seen.has(c.id)) continue;
seen.add(c.id);
list.push(c);
if(h.snippet && h.snippet !== (c.title || '')) snips.set(c.id, h.snippet);
}
for(const c of byTitle) if(!seen.has(c.id)) list.push(c);
return {list, snips, note};
}
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// La liste a changé (fin de tour, bascule, suppression) : une recherche en
// cours est refaite. Ses résultats restent affichés d'ici la nouvelle
// réponse — les effacer ferait clignoter la liste à chaque tour.
convSrvWanted = '';
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
@@ -105,14 +48,9 @@ function renderConversations(){
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
convSearchSchedule(q);
const {list, snips, note} = q ? convSearchList(q) : {list: CONVS, snips: new Map(), note: ''};
if(note){
const e = document.createElement('div');
e.className = 'conv-empty';
e.textContent = note;
cont.appendChild(e);
}
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
if(!list.length){
const e = document.createElement('div');
e.className = 'conv-empty';
@@ -127,8 +65,7 @@ function renderConversations(){
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
const row = document.createElement('div');
const sn = snips.get(c.id);
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : '');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
// qui travaille sans redessiner la liste (un redessin relancerait
// l'animation de l'anneau à chaque événement du flux).
@@ -141,14 +78,6 @@ function renderConversations(){
nm.appendChild(document.createTextNode(title));
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
info.appendChild(nm);
// Recherche : le passage du texte où les mots ont été trouvés.
if(sn){
const s = document.createElement('div');
s.className = 'conv-snip';
s.textContent = sn;
s.title = sn;
info.appendChild(s);
}
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
-8
View File
@@ -1762,14 +1762,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
/* Extrait d'un résultat de recherche plein-texte : sous le titre, à la place
de .preset-meta, une seule ligne tronquée comme le titre. */
.side .preset .conv-snip{font-size:12px;color:var(--dim);margin-top:3px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
/* Ligne à extrait : deux lignes de texte. L'heure et l'anneau se calent sur
la ligne de base du TITRE au lieu de se centrer entre les deux (les boutons,
en position absolue, restent centrés sur la ligne entière). */
.side .preset.has-snip{align-items:baseline}
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
border:0;padding:8px 11px;text-align:left;cursor:pointer}
.conv-more:hover{color:var(--text)}
+39 -1
View File
@@ -951,7 +951,6 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
if memEncActive() && memUnlocked() {
_ = reencryptChatStores()
}
startHistSearchBackfill() // index plein-texte : il attendait la clé (chat_search.go)
sendJSON(w, 200, map[string]any{"ok": true, "kind": kind, "health": memHealth()})
}
@@ -1167,6 +1166,9 @@ type mcpSaveReq struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Enabled *bool `json:"enabled"`
// CallTimeoutSec : délai d'un appel propre au serveur, en secondes
// (0 ou absent = réglage global).
CallTimeoutSec int `json:"callTimeoutSec"`
}
// handleMCP renvoie l'état de tous les serveurs MCP configurés (connexion tentée
@@ -1242,6 +1244,10 @@ func handleMCPSave(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if msg := mcpCallTimeoutSecError(req.CallTimeoutSec); msg != "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": msg})
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
@@ -1253,6 +1259,8 @@ func handleMCPSave(w http.ResponseWriter, r *http.Request) {
URL: strings.TrimSpace(req.URL),
Headers: req.Headers,
Enabled: enabled,
CallTimeoutSec: req.CallTimeoutSec,
}
if err := SetMCPServer(req.Name, cfg); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
@@ -1335,6 +1343,36 @@ func handleMCPTest(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMCPTimeouts renvoie les délais MCP globaux (secondes).
func handleMCPTimeouts(w http.ResponseWriter, r *http.Request) {
t := LoadMCPTimeouts()
sendJSON(w, 200, map[string]any{"ok": true, "connect": t.Connect, "call": t.Call})
}
// handleMCPTimeoutsSave enregistre les délais MCP globaux (1 à 3600 s) et
// ferme les sessions ouvertes, pour que le délai de connexion s'applique dès
// la suivante.
func handleMCPTimeoutsSave(w http.ResponseWriter, r *http.Request) {
var req MCPTimeouts
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if msg := mcpTimeoutsError(req); msg != "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": msg})
return
}
if err := SaveMCPTimeouts(req); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
servers, _ := LoadMCPConfig()
for name := range servers {
mcpInvalidate(name)
}
sendJSON(w, 200, map[string]any{"ok": true, "connect": req.Connect, "call": req.Call})
}
// handleMem / handleMemSave / handleMemDelete : éditeur web des pages mémoire
// (memory/<nom>.md). Payload partagé saveReq (name/old/content) ; "name" = nom
// de fichier de la page.
-5
View File
@@ -228,10 +228,6 @@ func handleInternalStore(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
// Une discussion écrite par la façade : l'index plein-texte la suit
// (chat_search.go). Avant la réponse, pour que sa suppression, qui
// n'arrive qu'après, ne soit jamais doublée par cette indexation.
histIndexStoreWrite(bucket, key, val, false)
w.WriteHeader(http.StatusNoContent)
case http.MethodDelete:
err := update(bucket, func(b *bolt.Bucket) error { return b.Delete([]byte(key)) })
@@ -239,7 +235,6 @@ func handleInternalStore(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
histIndexStoreWrite(bucket, key, nil, true)
w.WriteHeader(http.StatusNoContent)
default:
w.Header().Set("Allow", "GET, PUT, DELETE")
+2 -6
View File
@@ -163,9 +163,6 @@ func newWebMux() *http.ServeMux {
// orphelin arrêté, moteur relancé s'il tournait avant (backend_tune_lock.go)
// — au démarrage, puis toutes les 30 s (un stat sans verrou).
go tuneRecoverWatch(time.NewTicker(tuneRecoverEvery).C)
// Index plein-texte des discussions : rattrape celles d'avant la
// fonctionnalité (ou dont l'indexation a échoué), en tâche de fond.
startHistSearchBackfill()
mux := http.NewServeMux()
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
@@ -337,6 +334,8 @@ func newWebMux() *http.ServeMux {
api("/api/mcp/toggle", handleMCPToggle)
api("/api/mcp/tool", handleMCPTool)
api("/api/mcp/test", handleMCPTest)
api("/api/mcp/timeouts", handleMCPTimeouts)
api("/api/mcp/timeouts/save", handleMCPTimeoutsSave)
api("/api/memory", handleMemoryMode)
// Tâches planifiées : l'IA exécute des consignes toute seule sur une fréquence
// réglable (tasks.go). Le planificateur tourne dans CE process — celui qui
@@ -402,9 +401,6 @@ func newWebMux() *http.ServeMux {
api("/api/conversations/switch", handleConvSwitch)
api("/api/conversations/rename", handleConvRename)
api("/api/conversations/delete", handleConvDelete)
// Recherche plein-texte (chat_search.go) : servie ici même quand la façade
// tient la conversation — l'index est tenu par ce process.
api("/api/conversations/search", handleConvSearch)
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
-7
View File
@@ -499,11 +499,4 @@ mod tests {
);
assert!(is_internal("/internal/facade/v1/delta") && !is_internal("/internal/v1/auth"));
}
/// La recherche plein-texte reste au Go, qui tient l'index (chat_search.go) :
/// la façade la relaie, elle ne doit pas la servir.
#[test]
fn recherche_relayee_au_go() {
assert!(!handles("/api/conversations/search"));
}
}
+158 -32
View File
@@ -26,7 +26,7 @@ pub struct ServerConfig {
#[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`]).
/// secondes ; 0 = réglage global ([`Timeouts::call`]).
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
pub call_timeout_sec: i64,
}
@@ -285,21 +285,107 @@ pub fn prompt_line(tools: &[(String, String)]) -> String {
)
}
/// `mcpConnectTimeout`.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
/// `mcpConnectTimeoutCold` : npx et uvx téléchargent le paquet au premier
/// lancement.
/// lancement ; un minimum, un délai de connexion réglé plus long l'emporte.
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
/// `mcpCallTimeout`.
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// `mcpTimeoutMaxSec` : borne haute des réglages (1 h).
pub const TIMEOUT_MAX_SECS: i64 = 3600;
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
/// config.
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
/// config ; il suit la borne des réglages.
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(TIMEOUT_MAX_SECS as u64);
/// `mcpTimeoutsKey` : clé des délais globaux (`state/mcp_timeouts`).
pub const ST_TIMEOUTS: &str = "mcp_timeouts";
/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du
/// `MCPTimeouts` : les délais globaux, en secondes (`mcp_timeouts.go`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timeouts {
pub connect: i64,
pub call: i64,
}
impl Default for Timeouts {
/// 20 s de connexion, 120 s d'appel.
fn default() -> Self {
Timeouts {
connect: 0,
call: 0,
}
.normalized()
}
}
impl Timeouts {
/// `normalized` : nul ou négatif = défaut, au-delà de la borne = la borne.
pub fn normalized(self) -> Self {
let fix = |v: i64, def: i64| if v <= 0 { def } else { v.min(TIMEOUT_MAX_SECS) };
Timeouts {
connect: fix(self.connect, 20),
call: fix(self.call, 120),
}
}
pub fn connect(&self) -> Duration {
Duration::from_secs(self.connect.max(0) as u64)
}
pub fn call(&self) -> Duration {
Duration::from_secs(self.call.max(0) as u64)
}
/// `json.Marshal(MCPTimeouts)`.
pub fn marshal(&self) -> String {
format!(r#"{{"connect":{},"call":{}}}"#, self.connect, self.call)
}
}
/// Le type Go décodé (`MCPTimeouts`) et ses champs, pour [`crate::gojson`].
pub const TIMEOUTS_TYPE: &str = "MCPTimeouts";
pub const TIMEOUTS_FIELDS: [(&str, crate::gojson::Kind); 2] = [
("connect", crate::gojson::Kind::Int),
("call", crate::gojson::Kind::Int),
];
/// `LoadMCPTimeouts` : `getJSON` puis normalisation. Une valeur que
/// `json.Unmarshal` refuse, même en partie, donne les défauts.
pub fn decode_timeouts(raw: Option<&[u8]>) -> Timeouts {
let raw = raw.unwrap_or_default();
if raw.is_empty() || serde_json::from_slice::<Value>(raw).is_err() {
return Timeouts::default();
}
let d = crate::gojson::decode_struct(raw, TIMEOUTS_TYPE, &TIMEOUTS_FIELDS);
if d.err.is_some() {
return Timeouts::default();
}
Timeouts {
connect: d.int("connect"),
call: d.int("call"),
}
.normalized()
}
/// `mcpTimeoutsError` : le refus d'un réglage hors bornes.
pub fn timeouts_error(t: &Timeouts) -> Option<&'static str> {
if !(1..=TIMEOUT_MAX_SECS).contains(&t.connect) {
return Some("connexion : 1 à 3600 s");
}
if !(1..=TIMEOUT_MAX_SECS).contains(&t.call) {
return Some("appel d'outil : 1 à 3600 s");
}
None
}
/// `mcpCallTimeoutSecError` : le refus d'un délai par serveur hors bornes.
pub fn call_timeout_sec_error(sec: i64) -> Option<&'static str> {
if !(0..=TIMEOUT_MAX_SECS).contains(&sec) {
return Some("délai d'un appel : 0 à 3600 s (vide = réglage global)");
}
None
}
/// `mcpCallTimeoutFor` : le réglage global, 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;
pub fn call_timeout_for(cfg: &ServerConfig, tm: &Timeouts, args: &Map<String, Value>) -> Duration {
let mut d = tm.call();
if cfg.call_timeout_sec > 0 {
d = Duration::from_secs(cfg.call_timeout_sec as u64);
}
@@ -354,23 +440,27 @@ fn launcher(cfg: &ServerConfig) -> String {
b.strip_suffix(".cmd").unwrap_or(b).to_string()
}
/// `mcpTimeoutFor`.
pub fn timeout_for(cfg: &ServerConfig) -> Duration {
/// `mcpTimeoutFor` : le réglage global, au moins [`CONNECT_TIMEOUT_COLD`]
/// pour npx et uvx.
pub fn timeout_for(cfg: &ServerConfig, tm: &Timeouts) -> Duration {
match launcher(cfg).as_str() {
"npx" | "uvx" => CONNECT_TIMEOUT_COLD,
_ => CONNECT_TIMEOUT,
"npx" | "uvx" => tm.connect().max(CONNECT_TIMEOUT_COLD),
_ => tm.connect(),
}
}
/// `mcpFriendlyErr` pour un délai dépassé.
pub fn timeout_error(cfg: &ServerConfig) -> String {
pub fn timeout_error(cfg: &ServerConfig, tm: &Timeouts) -> String {
let l = launcher(cfg);
if l == "npx" || l == "uvx" {
return format!(
"délai dépassé — {l} télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)"
);
}
"délai dépassé — le serveur n'a pas répondu au handshake en 20s".into()
format!(
"délai dépassé — le serveur n'a pas répondu au handshake en {} (délai de connexion réglable dans les réglages MCP)",
crate::tools::shell::go_duration(tm.connect())
)
}
/// `mcpOutputCap` : environ un caractère par jeton de contexte, jamais sous
@@ -534,20 +624,24 @@ mod tests {
};
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));
let def = Timeouts::default();
let ct = |c: &ServerConfig, a: &Map<String, Value>| call_timeout_for(c, &def, a);
assert_eq!(ct(&cfg(0), &none), s(120));
assert_eq!(ct(&cfg(600), &none), s(600));
assert_eq!(ct(&cfg(0), &args(json!(300.0))), s(330));
assert_eq!(ct(&cfg(0), &args(json!(200000.0))), s(230));
assert_eq!(ct(&cfg(0), &args(json!(5000.0))), s(120));
assert_eq!(ct(&cfg(0), &args(json!("400"))), s(430));
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))),
ct(&cfg(0), &args(json!(86400.0 * 1000.0))),
CALL_TIMEOUT_MAX
);
assert_eq!(call_timeout_for(&cfg(30), &none), s(30));
assert_eq!(ct(&cfg(30), &none), s(30));
// Le réglage global, remplacé par celui du serveur, jamais rogné.
let tm = |call| Timeouts { connect: 20, call };
assert_eq!(call_timeout_for(&cfg(0), &tm(45), &none), s(45));
assert_eq!(call_timeout_for(&cfg(90), &tm(45), &none), s(90));
assert_eq!(call_timeout_for(&cfg(0), &tm(3600), &none), s(3600));
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)"
@@ -577,9 +671,41 @@ mod tests {
command: cmd.into(),
..Default::default()
};
assert_eq!(timeout_for(&c("/usr/bin/npx")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("UVX.EXE")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("npx.cmd")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("node")), CONNECT_TIMEOUT);
let def = Timeouts::default();
assert_eq!(timeout_for(&c("/usr/bin/npx"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("UVX.EXE"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("npx.cmd"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("node"), &def), Duration::from_secs(20));
// Un délai réglé plus long que le minimum des lanceurs l'emporte.
let long = Timeouts {
connect: 600,
call: 120,
};
assert_eq!(timeout_for(&c("npx"), &long), Duration::from_secs(600));
assert_eq!(timeout_for(&c("node"), &long), Duration::from_secs(600));
}
#[test]
fn delais_globaux() {
let d = |raw: &str| decode_timeouts(Some(raw.as_bytes()));
let t = |connect, call| Timeouts { connect, call };
assert_eq!(decode_timeouts(None), t(20, 120));
assert_eq!(d(r#"{"connect":5,"call":300}"#), t(5, 300));
assert_eq!(d(r#"{"connect":0,"call":0}"#), t(20, 120));
assert_eq!(d(r#"{"connect":-4,"call":99999}"#), t(20, 3600));
assert_eq!(d(r#"{"connect":"x","call":50}"#), t(20, 120));
assert_eq!(d(r#"{"CONNECT":7}"#), t(7, 120));
assert_eq!(d("null"), t(20, 120));
assert_eq!(d(r#"{"connect":30,"call":40} reste"#), t(20, 120));
assert_eq!(t(30, 600).marshal(), r#"{"connect":30,"call":600}"#);
assert_eq!(timeouts_error(&t(0, 120)), Some("connexion : 1 à 3600 s"));
assert_eq!(
timeouts_error(&t(20, 3601)),
Some("appel d'outil : 1 à 3600 s")
);
assert_eq!(timeouts_error(&t(3600, 1)), None);
assert!(call_timeout_sec_error(-1).is_some());
assert!(call_timeout_sec_error(0).is_none());
assert!(call_timeout_sec_error(3601).is_some());
}
}
+96 -16
View File
@@ -29,7 +29,7 @@ use crate::gojson::{self, Kind};
use crate::proxy::Body;
use crate::store::{BK_STATE, Store};
use client::Session;
use config::{ServerConfig, Servers};
use config::{ServerConfig, Servers, Timeouts};
/// Clé de la configuration (`state/mcp`).
pub const ST_MCP: &str = "mcp";
@@ -108,6 +108,26 @@ impl Mcp {
Ok(())
}
/// `SaveMCPTimeouts` puis la fermeture des sessions des serveurs
/// configurés : le nouveau délai de connexion vaut dès la suivante.
async fn save_timeouts(&self, store: &Store, t: Timeouts) -> Result<(), String> {
let names: Vec<String> = {
let _g = self.cfg_lock.lock().await;
store
.put(
BK_STATE,
config::ST_TIMEOUTS,
t.normalized().marshal().into_bytes(),
)
.await?;
Self::load(store).await.keys().cloned().collect()
};
for name in names {
self.invalidate(&name).await;
}
Ok(())
}
/// `mcpInvalidate` : la session du serveur fermée et oubliée, ses outils
/// retirés du registre.
pub async fn invalidate(&self, name: &str) {
@@ -136,8 +156,27 @@ impl Mcp {
}
}
/// `ensure` : la session du serveur, connectée ou en erreur.
async fn ensure(&self, name: &str, cfg: &ServerConfig, version: &str) -> Arc<Slot> {
/// `LoadMCPTimeouts` : les délais globaux (`state/mcp_timeouts`).
pub async fn timeouts(store: &Store) -> Timeouts {
config::decode_timeouts(
store
.get(BK_STATE, config::ST_TIMEOUTS)
.await
.ok()
.flatten()
.as_deref(),
)
}
/// `ensure` : la session du serveur, connectée ou en erreur. Le délai de
/// connexion est lu au moment de se connecter.
async fn ensure(
&self,
store: &Store,
name: &str,
cfg: &ServerConfig,
version: &str,
) -> Arc<Slot> {
let slot = lock(&self.slots)
.entry(name.to_string())
.or_insert_with(|| {
@@ -150,14 +189,15 @@ impl Mcp {
slot.state
.get_or_init(|| async {
let cfg = &slot.cfg;
let tm = Self::timeouts(store).await;
let mut st = SlotState::default();
let work = async {
let sess = Session::connect(cfg, version).await?;
let tools = sess.list_tools().await;
Ok::<_, String>((sess, tools))
};
match tokio::time::timeout(config::timeout_for(cfg), work).await {
Err(_) => st.err = Some(config::timeout_error(cfg)),
match tokio::time::timeout(config::timeout_for(cfg, &tm), work).await {
Err(_) => st.err = Some(config::timeout_error(cfg, &tm)),
Ok(Err(e)) => st.err = Some(e),
Ok(Ok((sess, tools))) => {
match tools {
@@ -191,12 +231,12 @@ impl Mcp {
}
/// `mcpEnsureAll` : les serveurs activés connectés en parallèle.
async fn ensure_all(&self, servers: &Servers, version: &str) {
async fn ensure_all(&self, store: &Store, servers: &Servers, version: &str) {
join_all(
servers
.iter()
.filter(|(_, c)| c.enabled && !c.transport().is_empty())
.map(|(n, c)| self.ensure(n, c, version)),
.map(|(n, c)| self.ensure(store, n, c, version)),
)
.await;
}
@@ -208,13 +248,13 @@ impl Mcp {
if servers.is_empty() {
return Vec::new();
}
self.ensure_all(&servers, version).await;
self.ensure_all(store, &servers, version).await;
let mut out = Vec::new();
for (name, cfg) in &servers {
if !cfg.enabled || cfg.transport().is_empty() {
continue;
}
let slot = self.ensure(name, cfg, version).await;
let slot = self.ensure(store, name, cfg, version).await;
let Some(st) = slot.state.get() else { continue };
if st.sess.is_none() {
continue;
@@ -255,8 +295,11 @@ impl Mcp {
};
let cap = config::output_cap(config::ctx_window(&store.config().await));
let servers = Self::load(store).await;
let timeout =
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
let timeout = config::call_timeout_for(
&servers.get(&server).cloned().unwrap_or_default(),
&Self::timeouts(store).await,
args,
);
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => e,
@@ -266,7 +309,7 @@ impl Mcp {
}
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
self.invalidate(&server).await;
self.ensure(&server, cfg, version).await;
self.ensure(store, &server, cfg, version).await;
match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => err = e,
@@ -307,7 +350,7 @@ impl Mcp {
/// activés.
pub async fn status(&self, store: &Store, version: &str) -> Vec<Value> {
let servers = Self::load(store).await;
self.ensure_all(&servers, version).await;
self.ensure_all(store, &servers, version).await;
let mut out = Vec::new();
for (name, cfg) in &servers {
let mut st = json!({
@@ -324,11 +367,13 @@ impl Mcp {
("env", json!(cfg.env)),
("url", json!(cfg.url)),
("headers", json!(cfg.headers)),
("callTimeoutSec", json!(cfg.call_timeout_sec)),
] {
let empty = match &v {
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
Value::Number(n) => n.as_i64() == Some(0),
_ => false,
};
if !empty {
@@ -336,7 +381,7 @@ impl Mcp {
}
}
if cfg.enabled && !cfg.transport().is_empty() {
let slot = self.ensure(name, cfg, version).await;
let slot = self.ensure(store, name, cfg, version).await;
if let Some(s) = slot.state.get() {
if s.sess.is_some() {
st["connected"] = json!(true);
@@ -367,7 +412,7 @@ impl Mcp {
}
let servers = Self::load(&store).await;
if !servers.is_empty() {
me.ensure_all(&servers, &version).await;
me.ensure_all(&store, &servers, &version).await;
}
});
}
@@ -494,6 +539,8 @@ pub fn handles(path: &str) -> bool {
| "/api/mcp/toggle"
| "/api/mcp/tool"
| "/api/mcp/test"
| "/api/mcp/timeouts"
| "/api/mcp/timeouts/save"
)
}
@@ -562,11 +609,16 @@ pub async fn serve(
("url", Kind::Str),
("headers", Kind::StrMap),
("enabled", Kind::OptBool),
("callTimeoutSec", Kind::Int),
],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let call_timeout_sec = d.int("callTimeoutSec");
if let Some(e) = config::call_timeout_sec_error(call_timeout_sec) {
return fail(StatusCode::BAD_REQUEST, e);
}
let cfg = ServerConfig {
command: d.str("command").trim().to_string(),
args: d.str_list("args").unwrap_or_default(),
@@ -579,7 +631,7 @@ pub async fn serve(
.collect(),
enabled: d.opt_bool("enabled").unwrap_or(true),
disabled_tools: Vec::new(),
call_timeout_sec: 0,
call_timeout_sec,
};
let name = d.str("name");
let res = mcp
@@ -654,6 +706,34 @@ pub async fn serve(
mcp.invalidate(&d.str("name")).await;
servers_ok(mcp.status(store, &version).await)
}
"/api/mcp/timeouts" => {
let t = Mcp::timeouts(store).await;
send_json(
StatusCode::OK,
&json!({ "ok": true, "connect": t.connect, "call": t.call }),
)
}
"/api/mcp/timeouts/save" => {
let raw = read_body(body).await;
let d = gojson::decode_struct(&raw, config::TIMEOUTS_TYPE, &config::TIMEOUTS_FIELDS);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let t = Timeouts {
connect: d.int("connect"),
call: d.int("call"),
};
if let Some(e) = config::timeouts_error(&t) {
return fail(StatusCode::BAD_REQUEST, e);
}
match mcp.save_timeouts(store, t).await {
Ok(()) => send_json(
StatusCode::OK,
&json!({ "ok": true, "connect": t.connect, "call": t.call }),
),
Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, e),
}
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
+136 -2
View File
@@ -66,17 +66,44 @@ fn parite_des_fonctions_pures() {
.unwrap_or_default();
assert_eq!(cf::prompt_line(&tools), s(&c["out"]), "{c}");
}
let def = cf::Timeouts::default();
for (cmd, v) in fx["timeouts"].as_object().unwrap() {
let cfg = cf::ServerConfig {
command: cmd.clone(),
..Default::default()
};
assert_eq!(
cf::timeout_for(&cfg).as_secs() as i64,
cf::timeout_for(&cfg, &def).as_secs() as i64,
v["secs"].as_i64().unwrap(),
"{cmd}"
);
assert_eq!(cf::timeout_error(&cfg), s(&v["err"]), "{cmd}");
assert_eq!(cf::timeout_error(&cfg, &def), s(&v["err"]), "{cmd}");
}
// Délais globaux : lecture de la base, puis leur effet.
let tm = |v: &Value| cf::Timeouts {
connect: v["connect"].as_i64().unwrap(),
call: v["call"].as_i64().unwrap(),
};
for c in fx["timeouts_stored"].as_array().unwrap() {
let raw = s(&c["in"]);
let got = cf::decode_timeouts((!raw.is_empty()).then_some(raw.as_bytes()));
assert_eq!(got, tm(&c["out"]), "{raw}");
}
for c in fx["timeouts_effects"].as_array().unwrap() {
let t = tm(&c["timeouts"]);
let cfg: cf::ServerConfig = serde_json::from_str(s(&c["cfg"])).unwrap();
let args: Map<String, Value> = serde_json::from_str(s(&c["args"])).unwrap();
assert_eq!(
cf::timeout_for(&cfg, &t).as_millis() as i64,
c["connect"].as_i64().unwrap(),
"{c}"
);
assert_eq!(cf::timeout_error(&cfg, &t), s(&c["err"]), "{c}");
assert_eq!(
cf::call_timeout_for(&cfg, &t, &args).as_millis() as i64,
c["call"].as_i64().unwrap(),
"{c}"
);
}
for c in fx["flatten"].as_array().unwrap() {
let v: Value = serde_json::from_str(s(&c["in"])).unwrap();
@@ -422,6 +449,50 @@ async fn routes_de_configuration() {
);
let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap();
assert_eq!(stored["web"]["url"], json!(url));
assert!(stored["web"].get("callTimeoutSec").is_none());
// Délai d'un appel propre au serveur : rangé, rendu, puis retiré quand le
// champ revient vide.
let body = |ct: Option<i64>| {
let mut b = json!({ "name": "web", "url": url, "headers": { "X-Cle": "k" } });
if let Some(n) = ct {
b["callTimeoutSec"] = json!(n);
}
b.to_string()
};
let (c, v) = post("/api/mcp/save", body(Some(900))).await;
assert_eq!(
(c, v["servers"][0]["callTimeoutSec"].clone()),
(200, json!(900))
);
let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap();
assert_eq!(stored["web"]["callTimeoutSec"], 900);
let (c, v) = post("/api/mcp/save", body(None)).await;
assert_eq!(c, 200);
assert!(v["servers"][0].get("callTimeoutSec").is_none(), "{v}");
for n in [-1, 3601] {
let (c, v) = post("/api/mcp/save", body(Some(n))).await;
assert_eq!(
(c, v["error"].clone()),
(
400,
json!("délai d'un appel : 0 à 3600 s (vide = réglage global)")
)
);
}
let (c, v) = post(
"/api/mcp/save",
r#"{"name":"x","url":"http://h","callTimeoutSec":"9"}"#.into(),
)
.await;
assert_eq!(
(c, v["error"].clone()),
(
400,
json!(
"json: cannot unmarshal string into Go struct field mcpSaveReq.callTimeoutSec of type int"
)
)
);
// Erreurs de la configuration et du JSON.
let (c, v) = post("/api/mcp/save", r#"{"name":"a__b","command":"x"}"#.into()).await;
assert_eq!(
@@ -506,6 +577,67 @@ async fn routes_de_configuration() {
pool.close_all().await;
}
/// Les routes des délais globaux rejouées contre la façade : mêmes codes,
/// mêmes réponses, même valeur rangée ; un enregistrement réussi ferme les
/// sessions ouvertes.
#[tokio::test]
async fn routes_des_delais() {
let tmp = tempfile::tempdir().unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
let fx = fixture();
let url = fake_server(fx["wire"].clone(), seen.clone()).await;
let pool = Arc::new(Mcp::new(tmp.path().to_path_buf()));
let (facade, go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
mcp: Some(pool.clone()),
..Default::default()
},
)
.await;
go.set(
"state",
"mcp",
&json!({ "web": { "url": url, "enabled": true } }).to_string(),
);
let initialize = || {
seen.lock()
.unwrap()
.iter()
.filter(|l| l.starts_with("initialize"))
.count()
};
let (_, b) = fakego::send(facade, "GET", "/api/mcp", &[], "").await;
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["servers"][0]["connected"], true, "{v}");
let mut connections = initialize();
for c in fx["timeouts_routes"].as_array().unwrap() {
let path = s(&c["path"]);
let method = if path.ends_with("/save") {
"POST"
} else {
"GET"
};
let (code, b) = fakego::send(facade, method, path, &[], s(&c["body"])).await;
assert_eq!(code.as_u16() as i64, c["code"].as_i64().unwrap(), "{c}");
let got: Value = serde_json::from_slice(&b).unwrap();
let want: Value = serde_json::from_str(s(&c["resp"])).unwrap();
assert_eq!(got, want, "{c}");
assert_eq!(
go.get("state", cf::ST_TIMEOUTS).unwrap_or_default(),
s(&c["state"]),
"{c}"
);
// Session fermée : la liste suivante se reconnecte.
fakego::send(facade, "GET", "/api/mcp", &[], "").await;
let now = initialize();
let saved = method == "POST" && code.as_u16() == 200;
assert_eq!(now, connections + usize::from(saved), "{c}");
connections = now;
}
pool.close_all().await;
}
#[tokio::test]
async fn catalogue_embarque() {
let tmp = tempfile::tempdir().unwrap();
@@ -544,6 +676,8 @@ async fn sans_la_variable_mcp_reste_au_go() {
"/api/mcp/save",
"/api/mcp/catalog",
"/api/mcp/test",
"/api/mcp/timeouts",
"/api/mcp/timeouts/save",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert_eq!(&b[..], format!("go:{p}").as_bytes());
+125 -125
View File
@@ -231,8 +231,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -260,7 +260,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -304,7 +304,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -335,8 +335,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -358,8 +358,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -387,8 +387,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -410,8 +410,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -433,8 +433,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -542,8 +542,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -571,7 +571,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -615,7 +615,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -855,8 +855,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -947,8 +947,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -993,8 +993,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1022,7 +1022,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1066,7 +1066,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -1103,8 +1103,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1132,8 +1132,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1241,8 +1241,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1270,7 +1270,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1314,7 +1314,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -1554,8 +1554,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1646,8 +1646,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1686,8 +1686,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16403.5,
"prompt_n": 32807,
"prompt_ms": 16395,
"prompt_n": 32790,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1715,7 +1715,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1759,7 +1759,7 @@
"max_tokens": 4096,
"messages": [
{
"content": "<131212 octets f427338939c0>",
"content": "<131145 octets 8d4ca4a3239d>",
"role": "user"
}
],
@@ -1790,8 +1790,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16403.5,
"prompt_n": 32807,
"prompt_ms": 16395,
"prompt_n": 32790,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1813,8 +1813,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16403.5,
"prompt_n": 32807,
"prompt_ms": 16395,
"prompt_n": 32790,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1882,7 +1882,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2049,7 +2049,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2093,7 +2093,7 @@
"max_tokens": 16,
"messages": [
{
"content": "<3745 octets f904cd9392eb>",
"content": "<3745 octets 4d9bad4c301c>",
"role": "user"
}
],
@@ -2187,8 +2187,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -2216,7 +2216,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2260,7 +2260,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -2291,8 +2291,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2314,8 +2314,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2368,7 +2368,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2420,8 +2420,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2443,8 +2443,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2497,7 +2497,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2541,7 +2541,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -2572,8 +2572,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2595,8 +2595,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2649,7 +2649,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2693,7 +2693,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -2747,8 +2747,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2770,8 +2770,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2824,7 +2824,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2868,7 +2868,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -2992,8 +2992,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3015,8 +3015,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3069,7 +3069,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3121,8 +3121,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3144,8 +3144,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3189,8 +3189,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3218,7 +3218,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3287,7 +3287,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -3319,8 +3319,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3342,8 +3342,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3383,8 +3383,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3412,7 +3412,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3457,7 +3457,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -3489,8 +3489,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3512,8 +3512,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3579,8 +3579,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3608,7 +3608,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3652,7 +3652,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -3737,8 +3737,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3760,8 +3760,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3823,7 +3823,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3867,7 +3867,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -3952,8 +3952,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3975,8 +3975,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4034,8 +4034,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4057,8 +4057,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4089,8 +4089,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4112,8 +4112,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4135,8 +4135,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
+446 -14
View File
@@ -163,37 +163,37 @@
{
"op": "set",
"name": "web",
"cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}",
"cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false,\"callTimeoutSec\":45}",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false,\"callTimeoutSec\":45}}"
},
{
"op": "enable",
"name": "web",
"on": true,
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "enable",
"name": "nope",
"on": true,
"error": "serveur MCP inconnu: nope",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
"name": "fs",
"tool": "write",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
"name": "fs",
"tool": "read",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\",\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\",\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
@@ -201,21 +201,21 @@
"tool": "write",
"on": true,
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
"name": "fs",
"tool": "read",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
"name": "nope",
"tool": "x",
"error": "serveur MCP inconnu: nope",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "delete",
@@ -382,13 +382,14 @@
"connected": false,
"tools": [],
"disabled": [],
"url": "{{URL}}"
"url": "{{URL}}",
"callTimeoutSec": 300
}
],
"store": "{\"alpha\":{\"url\":\"{{URL}}\",\"enabled\":true,\"disabledTools\":[\"secret\"]},\"beta\":{\"command\":\"loki-mcp-absent-pour-test\",\"enabled\":true},\"gamma\":{\"url\":\"{{URL}}\",\"enabled\":false},\"delta\":{\"enabled\":true}}",
"store": "{\"alpha\":{\"url\":\"{{URL}}\",\"enabled\":true,\"disabledTools\":[\"secret\"]},\"beta\":{\"command\":\"loki-mcp-absent-pour-test\",\"enabled\":true},\"gamma\":{\"url\":\"{{URL}}\",\"enabled\":false,\"callTimeoutSec\":300},\"delta\":{\"enabled\":true}}",
"timeouts": {
"": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"secs": 20
},
"/usr/local/bin/NPX": {
@@ -400,7 +401,7 @@
"secs": 180
},
"node": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"secs": 20
},
"npx": {
@@ -412,7 +413,7 @@
"secs": 180
},
"uvx-x": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"secs": 20
},
"uvx.exe": {
@@ -420,6 +421,437 @@
"secs": 180
}
},
"timeouts_effects": [
{
"args": "{}",
"call": 120000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 120000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 45000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 45000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 3600000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 3600000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 600000,
"err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":600}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 1000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{}",
"call": 1000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
}
],
"timeouts_routes": [
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":120,\"connect\":20,\"ok\":true}",
"state": ""
},
{
"body": "{\"connect\":0,\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"connexion : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":20,\"call\":3601}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"appel d'outil : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":20}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"appel d'outil : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":\"20\",\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal string into Go struct field MCPTimeouts.connect of type int\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":2.5,\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal number 2.5 into Go struct field MCPTimeouts.connect of type int\",\"ok\":false}",
"state": ""
},
{
"body": "[]",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal array into Go value of type loki.MCPTimeouts\",\"ok\":false}",
"state": ""
},
{
"body": "pas du json",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"invalid character 'p' looking for beginning of value\",\"ok\":false}",
"state": ""
},
{
"body": "{\"Connect\":45,\"CALL\":900,\"x\":1}",
"code": 200,
"path": "/api/mcp/timeouts/save",
"resp": "{\"call\":900,\"connect\":45,\"ok\":true}",
"state": "{\"connect\":45,\"call\":900}"
},
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":900,\"connect\":45,\"ok\":true}",
"state": "{\"connect\":45,\"call\":900}"
},
{
"body": "{\"connect\":3600,\"call\":1}",
"code": 200,
"path": "/api/mcp/timeouts/save",
"resp": "{\"call\":1,\"connect\":3600,\"ok\":true}",
"state": "{\"connect\":3600,\"call\":1}"
},
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":1,\"connect\":3600,\"ok\":true}",
"state": "{\"connect\":3600,\"call\":1}"
}
],
"timeouts_stored": [
{
"in": "",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":5,\"call\":300}",
"out": {
"connect": 5,
"call": 300
}
},
{
"in": "{\"connect\":0,\"call\":0}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":-4,\"call\":99999}",
"out": {
"connect": 20,
"call": 3600
}
},
{
"in": "{\"connect\":\"x\",\"call\":50}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"CONNECT\":7,\"Call\":8}",
"out": {
"connect": 7,
"call": 8
}
},
{
"in": "null",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "[1]",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":30,\"call\":40} reste",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":1.5,\"call\":60}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":3600,\"call\":3600,\"autre\":1}",
"out": {
"connect": 3600,
"call": 3600
}
}
],
"tool_server": [
{
"desc": "[MCP: alpha] desc",