mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge branch 'feat/mcp-catalog-go'
This commit is contained in:
commit
ebe04f9d61
15 files changed
+914
-4
No files matched your search
@@ -43,6 +43,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
|
||||
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
|
||||
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
|
||||
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
|
||||
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
|
||||
|
||||
@@ -154,13 +154,31 @@ Héritées d'AJEAN :
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python.
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
|
||||
fork la sert autrement que l'amont).
|
||||
|
||||
Ajoutées par ce fork :
|
||||
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
catégorie. Choisir une entrée **n'installe rien** : ça remplit le formulaire
|
||||
d'ajout, tu relis la commande — qui s'exécutera sur cette machine — puis tu
|
||||
enregistres. Le catalogue est un JSON **embarqué dans le binaire**
|
||||
(`internal/loki/mcp_catalog.json`), donc aucun appel à un annuaire distant :
|
||||
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
|
||||
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
|
||||
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
|
||||
- **Intensité du raisonnement** : en plus de l'interrupteur *Raisonnement*,
|
||||
l'éditeur de preset expose un niveau — auto / aucune / basse / moyenne / haute
|
||||
— envoyé à `llama-server` comme `reasoning_effort`. Réglage **par preset**,
|
||||
donc de fait par modèle. `none` coupe le raisonnement ; les autres valeurs sont
|
||||
passées au gabarit jinja du modèle, ce qui ne change le comportement que des
|
||||
modèles qui les lisent (gpt-oss et apparentés) — ailleurs c'est ignoré sans
|
||||
erreur, et le sous-titre du réglage le dit plutôt que de promettre un effet.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -70,6 +70,34 @@ func reasoningActive(v string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
|
||||
// "" (auto) n'y figure pas : c'est l'absence de réglage.
|
||||
var ReasoningEfforts = []string{"none", "low", "medium", "high"}
|
||||
|
||||
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
|
||||
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
|
||||
//
|
||||
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
|
||||
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
|
||||
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur —
|
||||
// c'est pourquoi il n'y a pas de repli à prévoir ici, contrairement à un backend
|
||||
// qui rejetterait la requête. En pratique seuls les modèles dont le gabarit
|
||||
// gère `reasoning_effort` (gpt-oss et apparentés) changent de comportement.
|
||||
func reasoningEffortValue(v string) string {
|
||||
switch s := strings.ToLower(strings.TrimSpace(v)); s {
|
||||
case "", "auto", "default":
|
||||
return "" // rien envoyé : le gabarit garde son comportement
|
||||
case "off", "false", "0", "no", "disable", "disabled", "none":
|
||||
return "none"
|
||||
case "low", "medium", "high":
|
||||
return s
|
||||
default:
|
||||
// Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse
|
||||
// passer au gabarit, qui l'ignorera s'il ne la connaît pas.
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
|
||||
// (manual override, with or without a leading '#') wins; otherwise it is
|
||||
// auto-detected from the MODEL= filename. Returns "" when unknown.
|
||||
|
||||
@@ -574,7 +574,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// reasoning is enabled we split that out ourselves so the UI's reasoning
|
||||
// bubble works regardless of backend. The ik_llama.cpp fork already sends
|
||||
// reasoning_content, in which case we leave content untouched.
|
||||
reasoningOn := reasoningActive(ReadConfig()["REASONING"])
|
||||
chatCfg := ReadConfig()
|
||||
reasoningOn := reasoningActive(chatCfg["REASONING"])
|
||||
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
|
||||
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
|
||||
reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
|
||||
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
|
||||
// retry the same turn once with tools removed so the model answers in plain
|
||||
// text from the tool results already gathered, instead of dying mid-chat.
|
||||
@@ -611,6 +615,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
if reasoningEffort != "" {
|
||||
payload["reasoning_effort"] = reasoningEffort
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
payload["tools"] = tools
|
||||
// The model sometimes emits parallel tool calls, which this llama.cpp
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package loki
|
||||
|
||||
// Catalogue MCP embarqué.
|
||||
//
|
||||
// Ajouter un serveur MCP demandait jusqu'ici d'écrire soi-même la commande et
|
||||
// ses arguments (`npx -y @scope/paquet …`) dans la modale. Le catalogue fournit
|
||||
// une liste de serveurs connus, déjà renseignés : un clic préremplit la modale,
|
||||
// l'utilisateur relit la commande et enregistre.
|
||||
//
|
||||
// Le catalogue est un JSON EMBARQUÉ dans le binaire (go:embed), pas un appel à
|
||||
// un annuaire distant : cohérent avec un Loki qui tourne hors ligne, et rien de
|
||||
// ce qui s'exécute sur la machine ne vient du réseau. Pour proposer un serveur
|
||||
// de plus : éditer mcp_catalog.json et recompiler.
|
||||
//
|
||||
// SÉCURITÉ : le catalogue ne fait qu'AFFICHER une commande. Il n'installe ni
|
||||
// n'exécute rien tout seul — c'est l'enregistrement du serveur par
|
||||
// l'utilisateur, via la modale existante, qui décide. Même niveau de confiance
|
||||
// que l'outil `bash` du mode agent (voir l'en-tête de mcp_config.go).
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed mcp_catalog.json
|
||||
var mcpCatalogRaw []byte
|
||||
|
||||
// MCPCatalogEntry décrit un serveur proposé par le catalogue. Les champs de
|
||||
// transport reprennent ceux de MCPServerConfig : l'UI peut les recopier tels
|
||||
// quels dans la modale d'ajout.
|
||||
type MCPCatalogEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
|
||||
// Runtime : binaire nécessaire pour lancer le serveur ("node" → npx,
|
||||
// "python" → uvx). Sert à signaler une entrée non installable ici.
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
|
||||
// Secrets : variables d'environnement que l'utilisateur DOIT renseigner
|
||||
// (jetons, clés d'API). L'UI les met en évidence ; elles sont volontairement
|
||||
// vides dans le catalogue.
|
||||
Secrets []string `json:"secrets,omitempty"`
|
||||
|
||||
// Renseignés à la lecture, pas dans le fichier.
|
||||
Installed bool `json:"installed"`
|
||||
RuntimeOK bool `json:"runtimeOk"`
|
||||
}
|
||||
|
||||
type mcpCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Servers []MCPCatalogEntry `json:"servers"`
|
||||
}
|
||||
|
||||
var (
|
||||
mcpCatalogOnce sync.Once
|
||||
mcpCatalogParsed []MCPCatalogEntry
|
||||
mcpCatalogParseEr error
|
||||
)
|
||||
|
||||
// mcpCatalogEntries décode le JSON embarqué une seule fois. Le contenu est figé
|
||||
// dans le binaire : inutile de le relire à chaque appel.
|
||||
func mcpCatalogEntries() ([]MCPCatalogEntry, error) {
|
||||
mcpCatalogOnce.Do(func() {
|
||||
var f mcpCatalogFile
|
||||
if err := json.Unmarshal(mcpCatalogRaw, &f); err != nil {
|
||||
mcpCatalogParseEr = fmt.Errorf("catalogue MCP illisible : %w", err)
|
||||
return
|
||||
}
|
||||
mcpCatalogParsed = f.Servers
|
||||
})
|
||||
return mcpCatalogParsed, mcpCatalogParseEr
|
||||
}
|
||||
|
||||
// mcpRuntimeAvailable indique si le binaire qui lance ce type de serveur est
|
||||
// présent. L'image conteneur embarque Node ; uv/uvx peut manquer.
|
||||
func mcpRuntimeAvailable(runtime string) bool {
|
||||
switch runtime {
|
||||
case "node":
|
||||
return lookPathOK("npx")
|
||||
case "python":
|
||||
return lookPathOK("uvx")
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func lookPathOK(bin string) bool {
|
||||
_, err := exec.LookPath(bin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// MCPCatalog renvoie les entrées du catalogue, chemins résolus et marquées
|
||||
// « déjà installée » (un serveur configuré porte le même nom que l'entrée).
|
||||
func MCPCatalog() ([]MCPCatalogEntry, error) {
|
||||
entries, err := mcpCatalogEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configured, err := LoadMCPConfig()
|
||||
if err != nil {
|
||||
// Une config MCP illisible ne doit pas vider le catalogue : on affiche
|
||||
// les entrées sans savoir lesquelles sont déjà là.
|
||||
configured = map[string]MCPServerConfig{}
|
||||
}
|
||||
|
||||
out := make([]MCPCatalogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
e.Args = expandCatalogSlice(e.Args)
|
||||
e.Env = expandCatalogMap(e.Env)
|
||||
_, e.Installed = configured[e.ID]
|
||||
e.RuntimeOK = mcpRuntimeAvailable(e.Runtime)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// expandCatalogToken remplace les jetons de chemin du catalogue. Seul
|
||||
// {workspace} existe : c'est le dossier de travail de l'agent, le seul chemin
|
||||
// qu'un serveur du catalogue a une raison légitime de recevoir par défaut.
|
||||
func expandCatalogToken(s string) string {
|
||||
if !strings.Contains(s, "{workspace}") {
|
||||
return s
|
||||
}
|
||||
return strings.ReplaceAll(s, "{workspace}", workspaceDir())
|
||||
}
|
||||
|
||||
func expandCatalogSlice(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = expandCatalogToken(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expandCatalogMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = expandCatalogToken(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "Catalogue MCP embarqué (hors ligne, aucun annuaire distant). Éditable à la main : ajoute une entrée pour la proposer dans l'interface, puis relance le build. Le jeton {workspace} est remplacé par le dossier de travail de l'agent. Les entrées « uvx --with mcp<2 … » visent des serveurs publiés avant le SDK MCP 2.0 : sans cet épinglage, ils plantent au démarrage (ImportError McpError).",
|
||||
"servers": [
|
||||
{
|
||||
"id": "filesystem",
|
||||
"name": "Filesystem",
|
||||
"category": "Fichiers",
|
||||
"description": "Lecture, écriture et recherche de fichiers, limitées aux dossiers passés en argument.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "{workspace}"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Git",
|
||||
"category": "Code",
|
||||
"description": "Historique, diff et statut d'un dépôt Git ; commits et branches.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-git", "--repository", "{workspace}"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"name": "Fetch",
|
||||
"category": "Web",
|
||||
"description": "Télécharge une URL et la convertit en Markdown pour le modèle.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-fetch"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"name": "Memory",
|
||||
"category": "Agent",
|
||||
"description": "Mémoire sous forme de graphe de connaissances (entités, relations, observations).",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-memory"],
|
||||
"env": { "MEMORY_FILE_PATH": "{workspace}/mcp-memory.json" },
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "sequential-thinking",
|
||||
"name": "Sequential Thinking",
|
||||
"category": "Agent",
|
||||
"description": "Structure le raisonnement en étapes révisables. Utile aux petits modèles locaux.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "time",
|
||||
"name": "Time",
|
||||
"category": "Utilitaires",
|
||||
"description": "Heure courante et conversions de fuseaux horaires.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-time"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"name": "SQLite",
|
||||
"category": "Données",
|
||||
"description": "Interroge et modifie une base SQLite locale.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/sqlite",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-sqlite", "--db-path", "{workspace}/agent.db"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "everything",
|
||||
"name": "Everything (test)",
|
||||
"category": "Utilitaires",
|
||||
"description": "Serveur de référence exposant toutes les primitives MCP. Sert à vérifier l'intégration.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "duckduckgo",
|
||||
"name": "DuckDuckGo",
|
||||
"category": "Web",
|
||||
"description": "Recherche web via DuckDuckGo, sans clé d'API.",
|
||||
"homepage": "https://pypi.org/project/duckduckgo-mcp-server/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["duckduckgo-mcp-server"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "playwright",
|
||||
"name": "Playwright",
|
||||
"category": "Web",
|
||||
"description": "Pilote un navigateur (navigation, clics, formulaires) via l'arbre d'accessibilité.",
|
||||
"homepage": "https://github.com/microsoft/playwright-mcp",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "puppeteer",
|
||||
"name": "Puppeteer",
|
||||
"category": "Web",
|
||||
"description": "Automatisation d'un Chrome sans interface (alternative à Playwright).",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/puppeteer",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "context7",
|
||||
"name": "Context7",
|
||||
"category": "Code",
|
||||
"description": "Documentation à jour des bibliothèques, injectée directement dans le contexte.",
|
||||
"homepage": "https://github.com/upstash/context7",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"name": "GitHub",
|
||||
"category": "Code",
|
||||
"description": "Dépôts, issues et pull requests via l'API GitHub. Nécessite un jeton personnel.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" },
|
||||
"secrets": ["GITHUB_PERSONAL_ACCESS_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "postgres",
|
||||
"name": "PostgreSQL",
|
||||
"category": "Données",
|
||||
"description": "Requêtes SQL en lecture seule et inspection du schéma d'une base PostgreSQL.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-postgres",
|
||||
"postgresql://utilisateur:motdepasse@hote:5432/base"
|
||||
],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"name": "Docker",
|
||||
"category": "Infra",
|
||||
"description": "Gère conteneurs et stacks compose. Nécessite l'accès au socket Docker.",
|
||||
"homepage": "https://pypi.org/project/docker-mcp/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "docker-mcp"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "brave-search",
|
||||
"name": "Brave Search",
|
||||
"category": "Web",
|
||||
"description": "Recherche web et locale via l'API Brave. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": { "BRAVE_API_KEY": "" },
|
||||
"secrets": ["BRAVE_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "firecrawl",
|
||||
"name": "Firecrawl",
|
||||
"category": "Web",
|
||||
"description": "Scraping et crawl de sites avec extraction structurée. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/firecrawl/firecrawl-mcp-server",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"env": { "FIRECRAWL_API_KEY": "" },
|
||||
"secrets": ["FIRECRAWL_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "notion",
|
||||
"name": "Notion",
|
||||
"category": "Productivité",
|
||||
"description": "Lecture et écriture de pages et bases Notion. Nécessite un jeton d'intégration.",
|
||||
"homepage": "https://github.com/makenotion/notion-mcp-server",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@notionhq/notion-mcp-server"],
|
||||
"env": { "NOTION_TOKEN": "" },
|
||||
"secrets": ["NOTION_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "slack",
|
||||
"name": "Slack",
|
||||
"category": "Productivité",
|
||||
"description": "Lecture des canaux et envoi de messages Slack. Nécessite un jeton de bot.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": { "SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": "" },
|
||||
"secrets": ["SLACK_BOT_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "google-maps",
|
||||
"name": "Google Maps",
|
||||
"category": "Utilitaires",
|
||||
"description": "Géocodage, itinéraires et recherche de lieux. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/google-maps",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-google-maps"],
|
||||
"env": { "GOOGLE_MAPS_API_KEY": "" },
|
||||
"secrets": ["GOOGLE_MAPS_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "sentry",
|
||||
"name": "Sentry",
|
||||
"category": "Infra",
|
||||
"description": "Récupère les issues et traces d'erreur d'un projet Sentry.",
|
||||
"homepage": "https://pypi.org/project/mcp-server-sentry/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-sentry", "--auth-token", "REMPLACE_MOI"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le catalogue est embarqué : s'il ne décode pas, le binaire livré est cassé et
|
||||
// le panneau MCP part vide. Ce test attrape une virgule en trop au build.
|
||||
func TestMCPCatalogDecodes(t *testing.T) {
|
||||
entries, err := mcpCatalogEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("catalogue illisible : %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("catalogue vide")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.ID == "" || e.Name == "" || e.Command == "" {
|
||||
t.Errorf("entrée incomplète : %+v", e)
|
||||
}
|
||||
if seen[e.ID] {
|
||||
t.Errorf("id dupliqué : %s", e.ID)
|
||||
}
|
||||
seen[e.ID] = true
|
||||
if e.Description == "" {
|
||||
t.Errorf("%s : description manquante", e.ID)
|
||||
}
|
||||
// Un secret annoncé doit exister comme variable d'environnement, sinon
|
||||
// l'UI met en avant un champ que l'utilisateur ne peut pas remplir.
|
||||
for _, s := range e.Secrets {
|
||||
if _, ok := e.Env[s]; !ok {
|
||||
t.Errorf("%s : secret %q absent de env", e.ID, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les jetons {workspace} doivent disparaître : un serveur lancé avec un chemin
|
||||
// littéral « {workspace} » démarre puis échoue à la première lecture.
|
||||
func TestMCPCatalogExpandsWorkspaceToken(t *testing.T) {
|
||||
entries, err := MCPCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("MCPCatalog : %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
for _, a := range e.Args {
|
||||
if strings.Contains(a, "{workspace}") {
|
||||
t.Errorf("%s : jeton non résolu dans args : %s", e.ID, a)
|
||||
}
|
||||
}
|
||||
for k, v := range e.Env {
|
||||
if strings.Contains(v, "{workspace}") {
|
||||
t.Errorf("%s : jeton non résolu dans env[%s] : %s", e.ID, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningEffortValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
" ": "",
|
||||
"auto": "",
|
||||
"default": "",
|
||||
"off": "none",
|
||||
"none": "none",
|
||||
"false": "none",
|
||||
"0": "none",
|
||||
"low": "low",
|
||||
"MEDIUM": "medium",
|
||||
" High ": "high",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := reasoningEffortValue(in); got != want {
|
||||
t.Errorf("reasoningEffortValue(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ var configTemplate = []struct{ key, help string }{
|
||||
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
|
||||
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
|
||||
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
|
||||
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high"},
|
||||
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
|
||||
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
|
||||
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
|
||||
|
||||
+170
-1
@@ -1038,6 +1038,17 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5}
|
||||
.mcp-edit:hover{opacity:1;color:var(--accent)}
|
||||
/* Liste des outils MCP dans la modale */
|
||||
.mcp-actions{display:flex;gap:6px;margin-top:6px}
|
||||
.mcp-cat-tabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}
|
||||
.mcp-cat{font-size:11px;padding:2px 9px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer}
|
||||
.mcp-cat:hover{border-color:var(--accent);color:var(--text)}
|
||||
.mcp-cat.on{border-color:var(--accent);color:var(--accent)}
|
||||
.mcp-cat-list{display:flex;flex-direction:column;gap:4px;max-height:52vh;overflow:auto}
|
||||
.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel)}
|
||||
.mcp-cat-row:hover{border-color:var(--accent)}
|
||||
.mcp-cat-desc{font-size:11px;color:var(--dim);margin-top:2px;line-height:1.35}
|
||||
.mcp-cat-cmd{font-size:10px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:340px}
|
||||
.mcp-cat-use{flex:none}
|
||||
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px}
|
||||
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer}
|
||||
.mcp-tool-row:hover{background:var(--panel)}
|
||||
@@ -2044,7 +2055,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<!-- 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. -->
|
||||
<div id="mcp-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div></div>
|
||||
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
|
||||
<div class="mcp-actions">
|
||||
<button class="addbtn" onclick="openMcpCatalog()">catalogue</button>
|
||||
<button class="addbtn" onclick="openMcp('')">+ ajouter un serveur</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2450,6 +2464,23 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-catalog-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcpCatalog()">
|
||||
<div class="modal-card" style="max-width:640px">
|
||||
<div class="modal-head">
|
||||
<strong>Catalogue MCP</strong>
|
||||
<button class="modal-x" onclick="closeMcpCatalog()" aria-label="Fermer"><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="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="muted" style="font-size:12px;margin-bottom:8px">
|
||||
Serveurs connus, embarqués dans Loki — aucun appel réseau pour cette liste.
|
||||
« Utiliser » remplit le formulaire d'ajout : la commande s'exécutera sur
|
||||
cette machine, relis-la avant d'enregistrer.
|
||||
</div>
|
||||
<div id="mcp-catalog-tabs" class="mcp-cat-tabs"></div>
|
||||
<div id="mcp-catalog-list" class="mcp-cat-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
@@ -2697,6 +2728,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row" id="s-reasoning-effort-row">
|
||||
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">envoyée au gabarit du modèle — auto = ne rien imposer</span></span>
|
||||
<span class="pe-row-c">
|
||||
<select id="s-reasoning-effort" class="pe-val" onchange="cfgWriteKey('REASONING_EFFORT', this.value)">
|
||||
<option value="">auto</option>
|
||||
<option value="none">aucune</option>
|
||||
<option value="low">basse</option>
|
||||
<option value="medium">moyenne</option>
|
||||
<option value="high">haute</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
|
||||
@@ -4515,6 +4558,20 @@ function populateSettings(){
|
||||
rzSub.textContent = rz ? 'réflexion étape par étape'
|
||||
: 'réflexion étape par étape — non précisé : le modèle décide';
|
||||
}
|
||||
// Intensité : passée telle quelle au gabarit jinja du modèle via
|
||||
// `reasoning_effort`. Seuls les gabarits qui la lisent (gpt-oss et apparentés)
|
||||
// changent de comportement ; ailleurs elle est ignorée sans erreur — le
|
||||
// sous-titre le dit pour ne pas laisser croire à un effet garanti.
|
||||
const eff = document.getElementById('s-reasoning-effort');
|
||||
if(eff){
|
||||
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
|
||||
const effSub = document.getElementById('s-reasoning-effort-sub');
|
||||
if(effSub){
|
||||
effSub.textContent = eff.value
|
||||
? 'envoyée au gabarit du modèle — sans effet si son gabarit l\'ignore'
|
||||
: 'envoyée au gabarit du modèle — auto = ne rien imposer';
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mlock', eaHasFlag('--mlock'));
|
||||
chk('s-nommap', eaHasFlag('--no-mmap'));
|
||||
@@ -6159,6 +6216,118 @@ async function toggleMcp(name, on){
|
||||
toast(on ? 'serveur activé' : 'serveur désactivé');
|
||||
}
|
||||
|
||||
// ─── Catalogue ───────────────────────────────────────────────────────────────
|
||||
// Liste embarquée de serveurs connus, servie par /api/mcp/catalog. Choisir une
|
||||
// entrée n'installe RIEN : ça ouvre la modale d'ajout préremplie, l'utilisateur
|
||||
// relit la commande (qui s'exécutera sur cette machine) puis enregistre.
|
||||
let mcpCatalog = [];
|
||||
let mcpCatalogFilter = 'Tous';
|
||||
|
||||
async function openMcpCatalog(){
|
||||
showModal('mcp-catalog-modal');
|
||||
const list = document.getElementById('mcp-catalog-list');
|
||||
if(!mcpCatalog.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">chargement…</div>';
|
||||
const r = await jget('/api/mcp/catalog');
|
||||
mcpCatalog = (r && r.catalog) || [];
|
||||
}
|
||||
renderMcpCatalog();
|
||||
}
|
||||
|
||||
function closeMcpCatalog(){ hideModal('mcp-catalog-modal'); }
|
||||
|
||||
function renderMcpCatalog(){
|
||||
const list = document.getElementById('mcp-catalog-list');
|
||||
const tabs = document.getElementById('mcp-catalog-tabs');
|
||||
list.textContent = '';
|
||||
tabs.textContent = '';
|
||||
|
||||
if(!mcpCatalog.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">(catalogue vide)</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cats = ['Tous'].concat([...new Set(mcpCatalog.map(e=>e.category))].sort());
|
||||
cats.forEach(c=>{
|
||||
const b = document.createElement('button');
|
||||
b.className = 'mcp-cat'+(c===mcpCatalogFilter?' on':'');
|
||||
b.textContent = c;
|
||||
b.onclick = ()=>{ mcpCatalogFilter = c; renderMcpCatalog(); };
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
|
||||
mcpCatalog
|
||||
.filter(e=>mcpCatalogFilter==='Tous' || e.category===mcpCatalogFilter)
|
||||
.forEach(e=>{
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mcp-cat-row';
|
||||
|
||||
const info = document.createElement('div'); info.className = 'mcp-info';
|
||||
const nm = document.createElement('div'); nm.className = 'mcp-name';
|
||||
nm.textContent = e.name;
|
||||
if(e.installed){
|
||||
const tag = document.createElement('span'); tag.className = 'mcp-tag';
|
||||
tag.textContent = 'installé'; tag.style.marginLeft = '6px';
|
||||
nm.appendChild(tag);
|
||||
}
|
||||
const desc = document.createElement('div'); desc.className = 'mcp-cat-desc';
|
||||
desc.textContent = e.description;
|
||||
const meta = document.createElement('div'); meta.className = 'mcp-meta';
|
||||
const cmd = document.createElement('code'); cmd.className = 'mcp-cat-cmd';
|
||||
cmd.textContent = [e.command].concat(e.args||[]).join(' ');
|
||||
cmd.title = cmd.textContent;
|
||||
meta.appendChild(cmd);
|
||||
if(!e.runtimeOk){
|
||||
const w = document.createElement('span'); w.className = 'mcp-err';
|
||||
w.textContent = 'runtime '+(e.runtime||'?')+' absent';
|
||||
w.title = e.runtime==='python'
|
||||
? "uv/uvx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer."
|
||||
: "npx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer.";
|
||||
meta.appendChild(w);
|
||||
}
|
||||
if((e.secrets||[]).length){
|
||||
const s = document.createElement('span'); s.className = 'mcp-tag';
|
||||
s.textContent = 'clé requise'; s.title = e.secrets.join(', ');
|
||||
meta.appendChild(s);
|
||||
}
|
||||
info.appendChild(nm); info.appendChild(desc); info.appendChild(meta);
|
||||
|
||||
const use = document.createElement('button');
|
||||
use.className = 'addbtn mcp-cat-use';
|
||||
use.textContent = e.installed ? 'revoir' : 'utiliser';
|
||||
use.onclick = ()=>{ closeMcpCatalog(); openMcpFromCatalog(e); };
|
||||
|
||||
row.appendChild(info); row.appendChild(use);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Préremplit la modale d'ajout à partir d'une entrée du catalogue. Le nom du
|
||||
// serveur = l'id de l'entrée, pour que « déjà installé » se détecte ensuite.
|
||||
function openMcpFromCatalog(e){
|
||||
openMcp(mcpServers.some(s=>s.name===e.id) ? e.id : '');
|
||||
if(!mcpServers.some(s=>s.name===e.id)){
|
||||
document.getElementById('mcp-name').value = e.id;
|
||||
document.querySelector('input[name="mcp-transport"][value="stdio"]').checked = true;
|
||||
mcpTransportUI('stdio');
|
||||
document.getElementById('mcp-command').value = e.command || '';
|
||||
document.getElementById('mcp-args').value = (e.args||[]).join('\n');
|
||||
document.getElementById('mcp-env').value =
|
||||
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
|
||||
document.getElementById('mcp-url').value = '';
|
||||
document.getElementById('mcp-headers').value = '';
|
||||
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
|
||||
}
|
||||
const st = document.getElementById('mcp-modal-status');
|
||||
if((e.secrets||[]).length){
|
||||
st.textContent = 'renseigne '+e.secrets.join(', ')+' avant d\'enregistrer';
|
||||
st.style.color = 'var(--err)';
|
||||
} else if(!e.runtimeOk){
|
||||
st.textContent = '⚠ runtime '+(e.runtime||'?')+' absent : le serveur ne démarrera pas ici';
|
||||
st.style.color = 'var(--err)';
|
||||
}
|
||||
}
|
||||
|
||||
// Ouvre la modale d'ajout (name='') ou d'édition (nom existant).
|
||||
function openMcp(name){
|
||||
mcpEditing = name || '';
|
||||
|
||||
@@ -168,7 +168,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<!-- 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. -->
|
||||
<div id="mcp-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div></div>
|
||||
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
|
||||
<div class="mcp-actions">
|
||||
<button class="addbtn" onclick="openMcpCatalog()">catalogue</button>
|
||||
<button class="addbtn" onclick="openMcp('')">+ ajouter un serveur</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -574,6 +577,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-catalog-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcpCatalog()">
|
||||
<div class="modal-card" style="max-width:640px">
|
||||
<div class="modal-head">
|
||||
<strong>Catalogue MCP</strong>
|
||||
<button class="modal-x" onclick="closeMcpCatalog()" aria-label="Fermer"><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="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="muted" style="font-size:12px;margin-bottom:8px">
|
||||
Serveurs connus, embarqués dans Loki — aucun appel réseau pour cette liste.
|
||||
« Utiliser » remplit le formulaire d'ajout : la commande s'exécutera sur
|
||||
cette machine, relis-la avant d'enregistrer.
|
||||
</div>
|
||||
<div id="mcp-catalog-tabs" class="mcp-cat-tabs"></div>
|
||||
<div id="mcp-catalog-list" class="mcp-cat-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
@@ -821,6 +841,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row" id="s-reasoning-effort-row">
|
||||
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">envoyée au gabarit du modèle — auto = ne rien imposer</span></span>
|
||||
<span class="pe-row-c">
|
||||
<select id="s-reasoning-effort" class="pe-val" onchange="cfgWriteKey('REASONING_EFFORT', this.value)">
|
||||
<option value="">auto</option>
|
||||
<option value="none">aucune</option>
|
||||
<option value="low">basse</option>
|
||||
<option value="medium">moyenne</option>
|
||||
<option value="high">haute</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
|
||||
|
||||
@@ -746,6 +746,20 @@ function populateSettings(){
|
||||
rzSub.textContent = rz ? 'réflexion étape par étape'
|
||||
: 'réflexion étape par étape — non précisé : le modèle décide';
|
||||
}
|
||||
// Intensité : passée telle quelle au gabarit jinja du modèle via
|
||||
// `reasoning_effort`. Seuls les gabarits qui la lisent (gpt-oss et apparentés)
|
||||
// changent de comportement ; ailleurs elle est ignorée sans erreur — le
|
||||
// sous-titre le dit pour ne pas laisser croire à un effet garanti.
|
||||
const eff = document.getElementById('s-reasoning-effort');
|
||||
if(eff){
|
||||
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
|
||||
const effSub = document.getElementById('s-reasoning-effort-sub');
|
||||
if(effSub){
|
||||
effSub.textContent = eff.value
|
||||
? 'envoyée au gabarit du modèle — sans effet si son gabarit l\'ignore'
|
||||
: 'envoyée au gabarit du modèle — auto = ne rien imposer';
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mlock', eaHasFlag('--mlock'));
|
||||
chk('s-nommap', eaHasFlag('--no-mmap'));
|
||||
|
||||
@@ -78,6 +78,118 @@ async function toggleMcp(name, on){
|
||||
toast(on ? 'serveur activé' : 'serveur désactivé');
|
||||
}
|
||||
|
||||
// ─── Catalogue ───────────────────────────────────────────────────────────────
|
||||
// Liste embarquée de serveurs connus, servie par /api/mcp/catalog. Choisir une
|
||||
// entrée n'installe RIEN : ça ouvre la modale d'ajout préremplie, l'utilisateur
|
||||
// relit la commande (qui s'exécutera sur cette machine) puis enregistre.
|
||||
let mcpCatalog = [];
|
||||
let mcpCatalogFilter = 'Tous';
|
||||
|
||||
async function openMcpCatalog(){
|
||||
showModal('mcp-catalog-modal');
|
||||
const list = document.getElementById('mcp-catalog-list');
|
||||
if(!mcpCatalog.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">chargement…</div>';
|
||||
const r = await jget('/api/mcp/catalog');
|
||||
mcpCatalog = (r && r.catalog) || [];
|
||||
}
|
||||
renderMcpCatalog();
|
||||
}
|
||||
|
||||
function closeMcpCatalog(){ hideModal('mcp-catalog-modal'); }
|
||||
|
||||
function renderMcpCatalog(){
|
||||
const list = document.getElementById('mcp-catalog-list');
|
||||
const tabs = document.getElementById('mcp-catalog-tabs');
|
||||
list.textContent = '';
|
||||
tabs.textContent = '';
|
||||
|
||||
if(!mcpCatalog.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">(catalogue vide)</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cats = ['Tous'].concat([...new Set(mcpCatalog.map(e=>e.category))].sort());
|
||||
cats.forEach(c=>{
|
||||
const b = document.createElement('button');
|
||||
b.className = 'mcp-cat'+(c===mcpCatalogFilter?' on':'');
|
||||
b.textContent = c;
|
||||
b.onclick = ()=>{ mcpCatalogFilter = c; renderMcpCatalog(); };
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
|
||||
mcpCatalog
|
||||
.filter(e=>mcpCatalogFilter==='Tous' || e.category===mcpCatalogFilter)
|
||||
.forEach(e=>{
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mcp-cat-row';
|
||||
|
||||
const info = document.createElement('div'); info.className = 'mcp-info';
|
||||
const nm = document.createElement('div'); nm.className = 'mcp-name';
|
||||
nm.textContent = e.name;
|
||||
if(e.installed){
|
||||
const tag = document.createElement('span'); tag.className = 'mcp-tag';
|
||||
tag.textContent = 'installé'; tag.style.marginLeft = '6px';
|
||||
nm.appendChild(tag);
|
||||
}
|
||||
const desc = document.createElement('div'); desc.className = 'mcp-cat-desc';
|
||||
desc.textContent = e.description;
|
||||
const meta = document.createElement('div'); meta.className = 'mcp-meta';
|
||||
const cmd = document.createElement('code'); cmd.className = 'mcp-cat-cmd';
|
||||
cmd.textContent = [e.command].concat(e.args||[]).join(' ');
|
||||
cmd.title = cmd.textContent;
|
||||
meta.appendChild(cmd);
|
||||
if(!e.runtimeOk){
|
||||
const w = document.createElement('span'); w.className = 'mcp-err';
|
||||
w.textContent = 'runtime '+(e.runtime||'?')+' absent';
|
||||
w.title = e.runtime==='python'
|
||||
? "uv/uvx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer."
|
||||
: "npx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer.";
|
||||
meta.appendChild(w);
|
||||
}
|
||||
if((e.secrets||[]).length){
|
||||
const s = document.createElement('span'); s.className = 'mcp-tag';
|
||||
s.textContent = 'clé requise'; s.title = e.secrets.join(', ');
|
||||
meta.appendChild(s);
|
||||
}
|
||||
info.appendChild(nm); info.appendChild(desc); info.appendChild(meta);
|
||||
|
||||
const use = document.createElement('button');
|
||||
use.className = 'addbtn mcp-cat-use';
|
||||
use.textContent = e.installed ? 'revoir' : 'utiliser';
|
||||
use.onclick = ()=>{ closeMcpCatalog(); openMcpFromCatalog(e); };
|
||||
|
||||
row.appendChild(info); row.appendChild(use);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Préremplit la modale d'ajout à partir d'une entrée du catalogue. Le nom du
|
||||
// serveur = l'id de l'entrée, pour que « déjà installé » se détecte ensuite.
|
||||
function openMcpFromCatalog(e){
|
||||
openMcp(mcpServers.some(s=>s.name===e.id) ? e.id : '');
|
||||
if(!mcpServers.some(s=>s.name===e.id)){
|
||||
document.getElementById('mcp-name').value = e.id;
|
||||
document.querySelector('input[name="mcp-transport"][value="stdio"]').checked = true;
|
||||
mcpTransportUI('stdio');
|
||||
document.getElementById('mcp-command').value = e.command || '';
|
||||
document.getElementById('mcp-args').value = (e.args||[]).join('\n');
|
||||
document.getElementById('mcp-env').value =
|
||||
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
|
||||
document.getElementById('mcp-url').value = '';
|
||||
document.getElementById('mcp-headers').value = '';
|
||||
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
|
||||
}
|
||||
const st = document.getElementById('mcp-modal-status');
|
||||
if((e.secrets||[]).length){
|
||||
st.textContent = 'renseigne '+e.secrets.join(', ')+' avant d\'enregistrer';
|
||||
st.style.color = 'var(--err)';
|
||||
} else if(!e.runtimeOk){
|
||||
st.textContent = '⚠ runtime '+(e.runtime||'?')+' absent : le serveur ne démarrera pas ici';
|
||||
st.style.color = 'var(--err)';
|
||||
}
|
||||
}
|
||||
|
||||
// Ouvre la modale d'ajout (name='') ou d'édition (nom existant).
|
||||
function openMcp(name){
|
||||
mcpEditing = name || '';
|
||||
|
||||
@@ -1007,6 +1007,17 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5}
|
||||
.mcp-edit:hover{opacity:1;color:var(--accent)}
|
||||
/* Liste des outils MCP dans la modale */
|
||||
.mcp-actions{display:flex;gap:6px;margin-top:6px}
|
||||
.mcp-cat-tabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}
|
||||
.mcp-cat{font-size:11px;padding:2px 9px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer}
|
||||
.mcp-cat:hover{border-color:var(--accent);color:var(--text)}
|
||||
.mcp-cat.on{border-color:var(--accent);color:var(--accent)}
|
||||
.mcp-cat-list{display:flex;flex-direction:column;gap:4px;max-height:52vh;overflow:auto}
|
||||
.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel)}
|
||||
.mcp-cat-row:hover{border-color:var(--accent)}
|
||||
.mcp-cat-desc{font-size:11px;color:var(--dim);margin-top:2px;line-height:1.35}
|
||||
.mcp-cat-cmd{font-size:10px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:340px}
|
||||
.mcp-cat-use{flex:none}
|
||||
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px}
|
||||
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer}
|
||||
.mcp-tool-row:hover{background:var(--panel)}
|
||||
|
||||
@@ -703,6 +703,18 @@ func handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
||||
}
|
||||
|
||||
// handleMCPCatalog renvoie le catalogue embarqué de serveurs MCP connus.
|
||||
// Lecture seule : rien n'est installé ici, l'UI ne fait que préremplir la modale
|
||||
// d'ajout avec la commande de l'entrée choisie.
|
||||
func handleMCPCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := MCPCatalog()
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "catalog": entries})
|
||||
}
|
||||
|
||||
// handleMCPSave ajoute ou remplace un serveur MCP.
|
||||
func handleMCPSave(w http.ResponseWriter, r *http.Request) {
|
||||
var req mcpSaveReq
|
||||
|
||||
@@ -218,6 +218,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
|
||||
api("/api/internet", handleInternet)
|
||||
api("/api/mcp", handleMCP)
|
||||
api("/api/mcp/catalog", handleMCPCatalog)
|
||||
api("/api/mcp/save", handleMCPSave)
|
||||
api("/api/mcp/delete", handleMCPDelete)
|
||||
api("/api/mcp/toggle", handleMCPToggle)
|
||||
|
||||
Reference in new issue
Block a user