mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be90a6941 | ||
|
|
f86937a1ae | ||
|
|
e9c0555ab7 |
No files matched your search
@@ -52,6 +52,14 @@ 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
|
||||
|
||||
@@ -173,6 +173,9 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,7 @@ 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)
|
||||
|
||||
@@ -37,15 +37,11 @@ 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, simple garde-fou : la façade borne elle-même la connexion
|
||||
// (1 h au plus) et l'appel, nouvel essai compris (mcpCallTimeoutMax chacun).
|
||||
// Délai large : un appel d'outil MCP peut durer (mcpCallTimeout), une
|
||||
// première connexion npx/uvx aussi (mcpConnectTimeoutCold).
|
||||
func facadeMCPJSON(path string, body any, out any) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
@@ -57,7 +53,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: mcpTimeoutMaxSec*time.Second + 2*mcpCallTimeoutMax}).Do(req)
|
||||
resp, err := (&http.Client{Timeout: mcpConnectTimeoutCold + mcpCallTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -74,27 +74,22 @@ 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{}, 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
|
||||
{MCPServerConfig{}, nil, mcpCallTimeout},
|
||||
{MCPServerConfig{CallTimeoutSec: 600}, nil, 10 * time.Minute},
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 5000.0}, mcpCallTimeout}, // 5 s : le défaut reste
|
||||
{MCPServerConfig{}, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne
|
||||
{MCPServerConfig{}, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond
|
||||
{MCPServerConfig{CallTimeoutSec: 30}, nil, 30 * time.Second}, // plus court que le défaut
|
||||
}
|
||||
for i, c := range cases {
|
||||
if got := mcpCallTimeoutFor(c.cfg, c.t, c.args); got != c.want {
|
||||
if got := mcpCallTimeoutFor(c.cfg, c.args); got != c.want {
|
||||
t.Errorf("cas %d : %s, attendu %s", i, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-38
@@ -36,17 +36,19 @@ 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
|
||||
@@ -192,12 +194,11 @@ func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession {
|
||||
m.mu.Unlock()
|
||||
|
||||
// Connexion hors-lock (peut être lente).
|
||||
timeouts := LoadMCPTimeouts()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg, timeouts))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg))
|
||||
defer cancel()
|
||||
sess, err := mcpConnect(ctx, name, cfg)
|
||||
if err != nil {
|
||||
s.err = mcpFriendlyErr(err, cfg, timeouts)
|
||||
s.err = mcpFriendlyErr(err, cfg)
|
||||
} else {
|
||||
s.sess = sess
|
||||
lt, lerr := sess.ListTools(ctx, nil)
|
||||
@@ -390,7 +391,7 @@ func mcpCall(parent context.Context, name string, args map[string]any) string {
|
||||
}
|
||||
servers, _ := LoadMCPConfig()
|
||||
cfg, exists := servers[ref.server]
|
||||
timeout := mcpCallTimeoutFor(cfg, LoadMCPTimeouts(), args)
|
||||
timeout := mcpCallTimeoutFor(cfg, args)
|
||||
|
||||
result, err := mcpCallOnce(parent, ref, args, timeout)
|
||||
if err != nil {
|
||||
@@ -423,16 +424,14 @@ 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.
|
||||
// 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
|
||||
const mcpCallTimeoutMax = 30 * time.Minute
|
||||
|
||||
// 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
|
||||
// mcpCallTimeoutFor : délai d'un appel. Défaut mcpCallTimeout, remplacé par
|
||||
// CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même
|
||||
// (beaucoup en ont un : navigateur, build, requête) l'allonge de 30 s pour que
|
||||
// l'outil ait le temps de rendre SA propre erreur de délai.
|
||||
func mcpCallTimeoutFor(cfg MCPServerConfig, t MCPTimeouts, args map[string]any) time.Duration {
|
||||
d := t.call()
|
||||
func mcpCallTimeoutFor(cfg MCPServerConfig, args map[string]any) time.Duration {
|
||||
d := mcpCallTimeout
|
||||
if cfg.CallTimeoutSec > 0 {
|
||||
d = time.Duration(cfg.CallTimeoutSec) * time.Second
|
||||
}
|
||||
@@ -599,8 +598,6 @@ 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
|
||||
@@ -624,9 +621,7 @@ func MCPStatus() ([]MCPServerStatus, error) {
|
||||
URL: cfg.URL,
|
||||
Headers: cfg.Headers,
|
||||
Tools: []string{},
|
||||
|
||||
CallTimeoutSec: cfg.CallTimeoutSec,
|
||||
Disabled: cfg.DisabledTools,
|
||||
Disabled: cfg.DisabledTools,
|
||||
}
|
||||
if st.Disabled == nil {
|
||||
st.Disabled = []string{}
|
||||
@@ -648,36 +643,29 @@ func MCPStatus() ([]MCPServerStatus, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
base := strings.ToLower(filepath.Base(cfg.Command))
|
||||
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
|
||||
return base == "npx" || base == "uvx"
|
||||
if base == "npx" || base == "uvx" {
|
||||
return mcpConnectTimeoutCold
|
||||
}
|
||||
return mcpConnectTimeout
|
||||
}
|
||||
|
||||
// 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, t MCPTimeouts) error {
|
||||
func mcpFriendlyErr(err error, cfg MCPServerConfig) error {
|
||||
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
if mcpColdLauncher(cfg) {
|
||||
base := strings.ToLower(filepath.Base(cfg.Command))
|
||||
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
|
||||
base := strings.ToLower(filepath.Base(cfg.Command))
|
||||
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
|
||||
if base == "npx" || base == "uvx" {
|
||||
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 (délai de connexion réglable dans les réglages MCP)", t.connect())
|
||||
return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s", mcpConnectTimeout)
|
||||
}
|
||||
@@ -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 = réglage global (MCPTimeouts.Call). Pour un serveur dont les outils sont longs
|
||||
// 0 = défaut (mcpCallTimeout). Pour un serveur dont les outils sont longs
|
||||
// par nature (build, scraping, requête lourde).
|
||||
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,14 +163,15 @@ 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), et les résultats complets des
|
||||
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil).
|
||||
// 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 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}
|
||||
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes, bkChatSearch}
|
||||
|
||||
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
|
||||
func reencryptChatStores() error {
|
||||
|
||||
@@ -79,6 +79,9 @@ 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()
|
||||
|
||||
@@ -91,76 +91,13 @@ 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, def).Seconds()),
|
||||
"err": mcpFriendlyErr(context.DeadlineExceeded, cfg, def).Error()}
|
||||
timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg).Seconds()),
|
||||
"err": mcpFriendlyErr(context.DeadlineExceeded, cfg).Error()}
|
||||
}
|
||||
fx["timeouts"] = timeouts
|
||||
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, "")
|
||||
fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}).Error()
|
||||
var flat []any
|
||||
for _, raw := range []string{
|
||||
`{"content":[{"type":"text","text":" un "},{"type":"text","text":"deux\n"}]}`,
|
||||
@@ -211,7 +148,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,"callTimeoutSec":45}`},
|
||||
{Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false}`},
|
||||
{Op: "enable", Name: "web", On: true},
|
||||
{Op: "enable", Name: "nope", On: true},
|
||||
{Op: "tool", Name: "fs", Tool: "write", On: false},
|
||||
@@ -348,7 +285,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, CallTimeoutSec: 300})
|
||||
_ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false})
|
||||
raw := getStr(bkState, "mcp")
|
||||
raw = strings.Replace(raw, `}}`, `},"delta":{"enabled":true}}`, 1)
|
||||
_ = putStr(bkState, "mcp", raw)
|
||||
|
||||
@@ -42,6 +42,11 @@ 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é.
|
||||
|
||||
+91
-76
@@ -1797,6 +1797,14 @@ 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)}
|
||||
@@ -2351,11 +2359,12 @@ 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 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. -->
|
||||
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. -->
|
||||
<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 une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
|
||||
<input id="conv-search" type="search" placeholder="rechercher partout…" oninput="renderConversations()" aria-label="rechercher dans les titres et le texte des discussions">
|
||||
<div id="conv-list" class="slist"></div>
|
||||
</details>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
@@ -2773,17 +2782,6 @@ 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. -->
|
||||
@@ -3303,10 +3301,6 @@ 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>
|
||||
@@ -9517,47 +9511,7 @@ 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'));
|
||||
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)';
|
||||
}
|
||||
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
|
||||
|
||||
function renderMCP(r){
|
||||
mcpServers = (r && r.servers) || [];
|
||||
@@ -9730,7 +9684,6 @@ 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');
|
||||
@@ -9758,8 +9711,6 @@ 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)'; }
|
||||
@@ -9838,13 +9789,6 @@ 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);
|
||||
@@ -10593,8 +10537,11 @@ 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
|
||||
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
|
||||
// titre, dates, nombre d'échanges — jamais les messages.
|
||||
// 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.
|
||||
|
||||
let CONVS = [], CONV_ACTIVE = '';
|
||||
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
|
||||
@@ -10608,11 +10555,65 @@ 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
|
||||
@@ -10631,9 +10632,14 @@ 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; }
|
||||
const list = q
|
||||
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
|
||||
: CONVS;
|
||||
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);
|
||||
}
|
||||
if(!list.length){
|
||||
const e = document.createElement('div');
|
||||
e.className = 'conv-empty';
|
||||
@@ -10648,7 +10654,8 @@ function renderConversations(){
|
||||
const shown = Math.max(CONV_SHOWN, ai + 1);
|
||||
for(const c of list.slice(0, shown)){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
const sn = snips.get(c.id);
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : '');
|
||||
// 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).
|
||||
@@ -10661,6 +10668,14 @@ 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
|
||||
|
||||
@@ -66,11 +66,12 @@ 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 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. -->
|
||||
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. -->
|
||||
<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 une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
|
||||
<input id="conv-search" type="search" placeholder="rechercher partout…" oninput="renderConversations()" aria-label="rechercher dans les titres et le texte des discussions">
|
||||
<div id="conv-list" class="slist"></div>
|
||||
</details>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
@@ -488,17 +489,6 @@ 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. -->
|
||||
@@ -1018,10 +1008,6 @@ 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>
|
||||
|
||||
@@ -5,47 +5,7 @@
|
||||
let mcpServers = [];
|
||||
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
|
||||
|
||||
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)';
|
||||
}
|
||||
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
|
||||
|
||||
function renderMCP(r){
|
||||
mcpServers = (r && r.servers) || [];
|
||||
@@ -218,7 +178,6 @@ 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');
|
||||
@@ -246,8 +205,6 @@ 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)'; }
|
||||
@@ -326,13 +283,6 @@ 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);
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
// balisage ni les gestionnaires d'événement.
|
||||
//
|
||||
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
|
||||
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
|
||||
// titre, dates, nombre d'échanges — jamais les messages.
|
||||
// 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.
|
||||
|
||||
let CONVS = [], CONV_ACTIVE = '';
|
||||
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
|
||||
@@ -25,11 +28,65 @@ 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
|
||||
@@ -48,9 +105,14 @@ 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; }
|
||||
const list = q
|
||||
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
|
||||
: CONVS;
|
||||
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);
|
||||
}
|
||||
if(!list.length){
|
||||
const e = document.createElement('div');
|
||||
e.className = 'conv-empty';
|
||||
@@ -65,7 +127,8 @@ function renderConversations(){
|
||||
const shown = Math.max(CONV_SHOWN, ai + 1);
|
||||
for(const c of list.slice(0, shown)){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
const sn = snips.get(c.id);
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : '');
|
||||
// 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).
|
||||
@@ -78,6 +141,14 @@ 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
|
||||
|
||||
@@ -1762,6 +1762,14 @@ 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)}
|
||||
|
||||
@@ -951,6 +951,7 @@ 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()})
|
||||
}
|
||||
|
||||
@@ -1166,9 +1167,6 @@ 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
|
||||
@@ -1244,10 +1242,6 @@ 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
|
||||
@@ -1259,8 +1253,6 @@ 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()})
|
||||
@@ -1343,36 +1335,6 @@ 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.
|
||||
|
||||
@@ -228,6 +228,10 @@ 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)) })
|
||||
@@ -235,6 +239,7 @@ 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")
|
||||
|
||||
@@ -163,6 +163,9 @@ 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é.
|
||||
@@ -334,8 +337,6 @@ 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
|
||||
@@ -401,6 +402,9 @@ 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
|
||||
|
||||
@@ -499,4 +499,11 @@ 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"));
|
||||
}
|
||||
}
|
||||
+32
-158
@@ -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 = réglage global ([`Timeouts::call`]).
|
||||
/// secondes ; 0 = défaut ([`CALL_TIMEOUT`]).
|
||||
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
|
||||
pub call_timeout_sec: i64,
|
||||
}
|
||||
@@ -285,107 +285,21 @@ 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 ; un minimum, un délai de connexion réglé plus long l'emporte.
|
||||
/// lancement.
|
||||
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
|
||||
/// `mcpTimeoutMaxSec` : borne haute des réglages (1 h).
|
||||
pub const TIMEOUT_MAX_SECS: i64 = 3600;
|
||||
/// `mcpCallTimeout`.
|
||||
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
|
||||
/// 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";
|
||||
/// config.
|
||||
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// `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
|
||||
/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du
|
||||
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
|
||||
pub fn call_timeout_for(cfg: &ServerConfig, tm: &Timeouts, args: &Map<String, Value>) -> Duration {
|
||||
let mut d = tm.call();
|
||||
pub fn call_timeout_for(cfg: &ServerConfig, args: &Map<String, Value>) -> Duration {
|
||||
let mut d = CALL_TIMEOUT;
|
||||
if cfg.call_timeout_sec > 0 {
|
||||
d = Duration::from_secs(cfg.call_timeout_sec as u64);
|
||||
}
|
||||
@@ -440,27 +354,23 @@ fn launcher(cfg: &ServerConfig) -> String {
|
||||
b.strip_suffix(".cmd").unwrap_or(b).to_string()
|
||||
}
|
||||
|
||||
/// `mcpTimeoutFor` : le réglage global, au moins [`CONNECT_TIMEOUT_COLD`]
|
||||
/// pour npx et uvx.
|
||||
pub fn timeout_for(cfg: &ServerConfig, tm: &Timeouts) -> Duration {
|
||||
/// `mcpTimeoutFor`.
|
||||
pub fn timeout_for(cfg: &ServerConfig) -> Duration {
|
||||
match launcher(cfg).as_str() {
|
||||
"npx" | "uvx" => tm.connect().max(CONNECT_TIMEOUT_COLD),
|
||||
_ => tm.connect(),
|
||||
"npx" | "uvx" => CONNECT_TIMEOUT_COLD,
|
||||
_ => CONNECT_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
/// `mcpFriendlyErr` pour un délai dépassé.
|
||||
pub fn timeout_error(cfg: &ServerConfig, tm: &Timeouts) -> String {
|
||||
pub fn timeout_error(cfg: &ServerConfig) -> 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)"
|
||||
);
|
||||
}
|
||||
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())
|
||||
)
|
||||
"délai dépassé — le serveur n'a pas répondu au handshake en 20s".into()
|
||||
}
|
||||
|
||||
/// `mcpOutputCap` : environ un caractère par jeton de contexte, jamais sous
|
||||
@@ -624,24 +534,20 @@ mod tests {
|
||||
};
|
||||
let s = Duration::from_secs;
|
||||
let none = Map::new();
|
||||
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), &none), CALL_TIMEOUT);
|
||||
assert_eq!(call_timeout_for(&cfg(600), &none), s(600));
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330));
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230));
|
||||
assert_eq!(
|
||||
ct(&cfg(0), &args(json!(86400.0 * 1000.0))),
|
||||
call_timeout_for(&cfg(0), &args(json!(5000.0))),
|
||||
CALL_TIMEOUT
|
||||
);
|
||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!("400"))), s(430));
|
||||
assert_eq!(
|
||||
call_timeout_for(&cfg(0), &args(json!(86400.0 * 1000.0))),
|
||||
CALL_TIMEOUT_MAX
|
||||
);
|
||||
assert_eq!(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_timeout_for(&cfg(30), &none), s(30));
|
||||
assert_eq!(
|
||||
call_deadline_error(s(330)),
|
||||
"[erreur MCP] pas de réponse après 5m30s (délai de l'appel ; un argument timeout plus grand l'allonge)"
|
||||
@@ -671,41 +577,9 @@ mod tests {
|
||||
command: cmd.into(),
|
||||
..Default::default()
|
||||
};
|
||||
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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
+16
-96
@@ -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, Timeouts};
|
||||
use config::{ServerConfig, Servers};
|
||||
|
||||
/// Clé de la configuration (`state/mcp`).
|
||||
pub const ST_MCP: &str = "mcp";
|
||||
@@ -108,26 +108,6 @@ 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) {
|
||||
@@ -156,27 +136,8 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
/// `ensure` : la session du serveur, connectée ou en erreur.
|
||||
async fn ensure(&self, name: &str, cfg: &ServerConfig, version: &str) -> Arc<Slot> {
|
||||
let slot = lock(&self.slots)
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| {
|
||||
@@ -189,15 +150,14 @@ 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, &tm), work).await {
|
||||
Err(_) => st.err = Some(config::timeout_error(cfg, &tm)),
|
||||
match tokio::time::timeout(config::timeout_for(cfg), work).await {
|
||||
Err(_) => st.err = Some(config::timeout_error(cfg)),
|
||||
Ok(Err(e)) => st.err = Some(e),
|
||||
Ok(Ok((sess, tools))) => {
|
||||
match tools {
|
||||
@@ -231,12 +191,12 @@ impl Mcp {
|
||||
}
|
||||
|
||||
/// `mcpEnsureAll` : les serveurs activés connectés en parallèle.
|
||||
async fn ensure_all(&self, store: &Store, servers: &Servers, version: &str) {
|
||||
async fn ensure_all(&self, servers: &Servers, version: &str) {
|
||||
join_all(
|
||||
servers
|
||||
.iter()
|
||||
.filter(|(_, c)| c.enabled && !c.transport().is_empty())
|
||||
.map(|(n, c)| self.ensure(store, n, c, version)),
|
||||
.map(|(n, c)| self.ensure(n, c, version)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -248,13 +208,13 @@ impl Mcp {
|
||||
if servers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.ensure_all(store, &servers, version).await;
|
||||
self.ensure_all(&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(store, name, cfg, version).await;
|
||||
let slot = self.ensure(name, cfg, version).await;
|
||||
let Some(st) = slot.state.get() else { continue };
|
||||
if st.sess.is_none() {
|
||||
continue;
|
||||
@@ -295,11 +255,8 @@ 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(),
|
||||
&Self::timeouts(store).await,
|
||||
args,
|
||||
);
|
||||
let timeout =
|
||||
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
|
||||
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
|
||||
Ok(r) => return r,
|
||||
Err(e) => e,
|
||||
@@ -309,7 +266,7 @@ impl Mcp {
|
||||
}
|
||||
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
|
||||
self.invalidate(&server).await;
|
||||
self.ensure(store, &server, cfg, version).await;
|
||||
self.ensure(&server, cfg, version).await;
|
||||
match self.call_once(&server, &tool, args, cap, timeout).await {
|
||||
Ok(r) => return r,
|
||||
Err(e) => err = e,
|
||||
@@ -350,7 +307,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(store, &servers, version).await;
|
||||
self.ensure_all(&servers, version).await;
|
||||
let mut out = Vec::new();
|
||||
for (name, cfg) in &servers {
|
||||
let mut st = json!({
|
||||
@@ -367,13 +324,11 @@ 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 {
|
||||
@@ -381,7 +336,7 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
if cfg.enabled && !cfg.transport().is_empty() {
|
||||
let slot = self.ensure(store, name, cfg, version).await;
|
||||
let slot = self.ensure(name, cfg, version).await;
|
||||
if let Some(s) = slot.state.get() {
|
||||
if s.sess.is_some() {
|
||||
st["connected"] = json!(true);
|
||||
@@ -412,7 +367,7 @@ impl Mcp {
|
||||
}
|
||||
let servers = Self::load(&store).await;
|
||||
if !servers.is_empty() {
|
||||
me.ensure_all(&store, &servers, &version).await;
|
||||
me.ensure_all(&servers, &version).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -539,8 +494,6 @@ pub fn handles(path: &str) -> bool {
|
||||
| "/api/mcp/toggle"
|
||||
| "/api/mcp/tool"
|
||||
| "/api/mcp/test"
|
||||
| "/api/mcp/timeouts"
|
||||
| "/api/mcp/timeouts/save"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -609,16 +562,11 @@ 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(),
|
||||
@@ -631,7 +579,7 @@ pub async fn serve(
|
||||
.collect(),
|
||||
enabled: d.opt_bool("enabled").unwrap_or(true),
|
||||
disabled_tools: Vec::new(),
|
||||
call_timeout_sec,
|
||||
call_timeout_sec: 0,
|
||||
};
|
||||
let name = d.str("name");
|
||||
let res = mcp
|
||||
@@ -706,34 +654,6 @@ 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"),
|
||||
}
|
||||
}
|
||||
|
||||
+2
-136
@@ -66,44 +66,17 @@ 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, &def).as_secs() as i64,
|
||||
cf::timeout_for(&cfg).as_secs() as i64,
|
||||
v["secs"].as_i64().unwrap(),
|
||||
"{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}"
|
||||
);
|
||||
assert_eq!(cf::timeout_error(&cfg), s(&v["err"]), "{cmd}");
|
||||
}
|
||||
for c in fx["flatten"].as_array().unwrap() {
|
||||
let v: Value = serde_json::from_str(s(&c["in"])).unwrap();
|
||||
@@ -449,50 +422,6 @@ 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!(
|
||||
@@ -577,67 +506,6 @@ 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();
|
||||
@@ -676,8 +544,6 @@ 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());
|
||||
|
||||
@@ -231,8 +231,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -304,7 +304,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -335,8 +335,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -358,8 +358,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -387,8 +387,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -410,8 +410,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -433,8 +433,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -542,8 +542,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -571,7 +571,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -615,7 +615,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -855,8 +855,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -947,8 +947,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -993,8 +993,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1022,7 +1022,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1066,7 +1066,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1103,8 +1103,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1132,8 +1132,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1241,8 +1241,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1270,7 +1270,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1314,7 +1314,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1554,8 +1554,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1646,8 +1646,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1686,8 +1686,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16395,
|
||||
"prompt_n": 32790,
|
||||
"prompt_ms": 16403.5,
|
||||
"prompt_n": 32807,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -1715,7 +1715,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -1759,7 +1759,7 @@
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<131145 octets 8d4ca4a3239d>",
|
||||
"content": "<131212 octets f427338939c0>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -1790,8 +1790,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16395,
|
||||
"prompt_n": 32790,
|
||||
"prompt_ms": 16403.5,
|
||||
"prompt_n": 32807,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1813,8 +1813,8 @@
|
||||
"predicted_ms": 81920,
|
||||
"predicted_n": 4096,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 16395,
|
||||
"prompt_n": 32790,
|
||||
"prompt_ms": 16403.5,
|
||||
"prompt_n": 32807,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -1882,7 +1882,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2049,7 +2049,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2093,7 +2093,7 @@
|
||||
"max_tokens": 16,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<3745 octets 4d9bad4c301c>",
|
||||
"content": "<3745 octets f904cd9392eb>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2187,8 +2187,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -2216,7 +2216,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2260,7 +2260,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2291,8 +2291,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2314,8 +2314,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2368,7 +2368,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2420,8 +2420,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2443,8 +2443,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2497,7 +2497,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2541,7 +2541,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2572,8 +2572,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2595,8 +2595,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2649,7 +2649,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2693,7 +2693,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2747,8 +2747,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2770,8 +2770,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -2824,7 +2824,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -2868,7 +2868,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -2992,8 +2992,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3015,8 +3015,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3069,7 +3069,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3121,8 +3121,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3144,8 +3144,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3189,8 +3189,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3218,7 +3218,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3287,7 +3287,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3319,8 +3319,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3342,8 +3342,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3383,8 +3383,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3412,7 +3412,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3457,7 +3457,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3489,8 +3489,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3512,8 +3512,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3579,8 +3579,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
},
|
||||
@@ -3608,7 +3608,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3652,7 +3652,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3737,8 +3737,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3760,8 +3760,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3823,7 +3823,7 @@
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"content": "<163664 octets b7452851885a>"
|
||||
"content": "<163821 octets 62a166d2e838>"
|
||||
},
|
||||
"method": "POST",
|
||||
"path": "/tokenize"
|
||||
@@ -3867,7 +3867,7 @@
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"content": "<8130 octets 081c43a7796f>",
|
||||
"content": "<8113 octets 6818f5613608>",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
@@ -3952,8 +3952,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -3975,8 +3975,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4034,8 +4034,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4057,8 +4057,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4089,8 +4089,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4112,8 +4112,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
@@ -4135,8 +4135,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 1016,
|
||||
"prompt_n": 2032,
|
||||
"prompt_per_second": 2000,
|
||||
"protocol": 2
|
||||
}
|
||||
|
||||
@@ -163,37 +163,37 @@
|
||||
{
|
||||
"op": "set",
|
||||
"name": "web",
|
||||
"cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false,\"callTimeoutSec\":45}",
|
||||
"cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}",
|
||||
"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,\"callTimeoutSec\":45}}"
|
||||
"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}}"
|
||||
},
|
||||
{
|
||||
"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,\"callTimeoutSec\":45}}"
|
||||
"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}}"
|
||||
},
|
||||
{
|
||||
"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,\"callTimeoutSec\":45}}"
|
||||
"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}}"
|
||||
},
|
||||
{
|
||||
"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,\"callTimeoutSec\":45}}"
|
||||
"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}}"
|
||||
},
|
||||
{
|
||||
"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,\"callTimeoutSec\":45}}"
|
||||
"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}}"
|
||||
},
|
||||
{
|
||||
"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,\"callTimeoutSec\":45}}"
|
||||
"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}}"
|
||||
},
|
||||
{
|
||||
"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,\"callTimeoutSec\":45}}"
|
||||
"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}}"
|
||||
},
|
||||
{
|
||||
"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,\"callTimeoutSec\":45}}"
|
||||
"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}}"
|
||||
},
|
||||
{
|
||||
"op": "delete",
|
||||
@@ -382,14 +382,13 @@
|
||||
"connected": false,
|
||||
"tools": [],
|
||||
"disabled": [],
|
||||
"url": "{{URL}}",
|
||||
"callTimeoutSec": 300
|
||||
"url": "{{URL}}"
|
||||
}
|
||||
],
|
||||
"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}}",
|
||||
"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}}",
|
||||
"timeouts": {
|
||||
"": {
|
||||
"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)",
|
||||
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
|
||||
"secs": 20
|
||||
},
|
||||
"/usr/local/bin/NPX": {
|
||||
@@ -401,7 +400,7 @@
|
||||
"secs": 180
|
||||
},
|
||||
"node": {
|
||||
"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)",
|
||||
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
|
||||
"secs": 20
|
||||
},
|
||||
"npx": {
|
||||
@@ -413,7 +412,7 @@
|
||||
"secs": 180
|
||||
},
|
||||
"uvx-x": {
|
||||
"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)",
|
||||
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
|
||||
"secs": 20
|
||||
},
|
||||
"uvx.exe": {
|
||||
@@ -421,437 +420,6 @@
|
||||
"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",
|
||||
|
||||
Reference in new issue
Block a user