diff --git a/.env.example b/.env.example index 8d747ae..6a0b6ab 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,9 @@ PORT=8717 # Optionnel : URL d'une instance SearxNG pour l'outil web_search. # Si vide, web_search utilise DuckDuckGo (sans clé d'API). SEARX_URL= + +# Optionnel : VRAM du GPU (en Mo) pour l'auto-réglage, si Ollama tourne sur une +# autre machine (la détection nvidia-smi ne voit pas un GPU distant). +# Ex. 12000 pour une carte 12 Go. 0 = détection automatique. +GPU_VRAM_MB=0 +GPU_NAME= diff --git a/README.md b/README.md index 8132f2d..5206ed4 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,26 @@ npm run dev # http://localhost:5173 | `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé | | `run_shell` | Exécuter une commande **(sensible)** | désactivé | +## Auto-réglage GPU (« Réglage auto ») + +Dans **Configuration → Génération**, le bouton **⚡ Réglage auto** : +1. détecte le GPU (VRAM) via `nvidia-smi`/`rocm-smi`, ou la valeur déclarée + `GPU_VRAM_MB` ; +2. lit les métadonnées du modèle sélectionné via Ollama (contexte max, + architecture, taille, quantization) ; +3. calcule la **fenêtre de contexte (`num_ctx`)** la plus grande qui tient en + VRAM (estimation du cache KV) et un nombre de **jetons max** cohérent, puis + les applique ; +4. vérifie le **placement réel** du modèle via `/api/ps` d'Ollama (GPU / CPU). + +> **Ollama distant** : si Ollama tourne sur une autre machine que Loki, la +> détection `nvidia-smi` ne voit pas ce GPU. Déclare alors la VRAM avec +> `GPU_VRAM_MB` (ex. `12000` pour une carte 12 Go) pour un réglage précis. +> +> **Pourquoi c'est utile** : un `num_ctx` trop grand fait déborder le modèle sur +> le CPU (lent). En l'ajustant à ta VRAM, le modèle reste sur le GPU. Laisse +> `num_ctx = 0` (auto) pour utiliser le défaut du modèle. + ## Sécurité - **Confinement** : toutes les opérations fichier (`read_file`, `write_file`, diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py index 32e8313..d08d719 100644 --- a/backend/app/agent_config.py +++ b/backend/app/agent_config.py @@ -34,6 +34,8 @@ DEFAULT_CONFIG: dict = { "top_p": 0.9, "top_k": 40, "max_tokens": 2048, + # Fenêtre de contexte envoyée à Ollama (0 = laisser le défaut du modèle). + "num_ctx": 0, "tools": dict(DEFAULT_TOOL_STATE), # Demander une validation utilisateur avant toute commande shell. "confirm_shell": True, @@ -69,12 +71,16 @@ def save_config(patch: dict) -> dict: def ollama_options(cfg: dict) -> dict: """Traduit la config en options de génération Ollama.""" - return { + opts = { "temperature": cfg["temperature"], "top_p": cfg["top_p"], "top_k": cfg["top_k"], "num_predict": cfg["max_tokens"], } + # num_ctx n'est envoyé que s'il est défini (> 0), sinon défaut du modèle. + if cfg.get("num_ctx"): + opts["num_ctx"] = cfg["num_ctx"] + return opts def enabled_tool_names(cfg: dict) -> list[str]: diff --git a/backend/app/autotune.py b/backend/app/autotune.py index 68dcfbf..d8b306d 100644 --- a/backend/app/autotune.py +++ b/backend/app/autotune.py @@ -210,6 +210,31 @@ async def recommend(model: str) -> dict: } +async def placement(model: str) -> dict: + """Lit /api/ps : le modèle est-il chargé sur GPU, CPU, ou un mix ?""" + try: + loaded = await ollama.ps() + except (httpx.HTTPError, OSError): + return {"loaded": False} + + for m in loaded: + if m.get("name") == model or m.get("model") == model: + size = m.get("size", 0) or 0 + size_vram = m.get("size_vram", 0) or 0 + if size <= 0: + where = "inconnu" + elif size_vram >= size * 0.99: + where = "gpu" + elif size_vram <= size * 0.01: + where = "cpu" + else: + where = "mixte" + pct = int(size_vram / size * 100) if size else 0 + return {"loaded": True, "where": where, "gpu_percent": pct, + "size_mb": size // (1024 * 1024)} + return {"loaded": False} + + def _fallback_model_mb(prof: dict) -> int: """Estime la taille des poids si /api/tags n'a rien donné.""" ps = (prof.get("parameter_size") or "").upper().replace("B", "") diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py index 0c59951..49c5b50 100644 --- a/backend/app/ollama_client.py +++ b/backend/app/ollama_client.py @@ -33,6 +33,13 @@ class OllamaClient: resp.raise_for_status() return resp.json().get("models", []) + async def ps(self) -> list[dict]: + """Modèles actuellement chargés et leur répartition VRAM/CPU (/api/ps).""" + async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client: + resp = await client.get(f"{self.host}/api/ps") + resp.raise_for_status() + return resp.json().get("models", []) + async def show(self, name: str) -> dict: """Métadonnées détaillées d'un modèle (/api/show).""" async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index 9f11ce0..74cdb6b 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -1,10 +1,11 @@ -"""Routes de configuration de l'agent (lecture / mise à jour).""" +"""Routes de configuration de l'agent (lecture / mise à jour / auto-réglage).""" from __future__ import annotations from fastapi import APIRouter from pydantic import BaseModel -from .. import agent_config +from .. import agent_config, autotune +from ..config import settings router = APIRouter(prefix="/api/config", tags=["config"]) @@ -15,7 +16,14 @@ class ConfigPatch(BaseModel): top_p: float | None = None top_k: int | None = None max_tokens: int | None = None + num_ctx: int | None = None tools: dict[str, bool] | None = None + confirm_shell: bool | None = None + + +class AutoTuneRequest(BaseModel): + model: str + apply: bool = True @router.get("") @@ -30,3 +38,27 @@ async def get_config() -> dict: async def put_config(patch: ConfigPatch) -> dict: cfg = agent_config.save_config(patch.model_dump(exclude_none=True)) return {"config": cfg} + + +@router.post("/auto") +async def auto_tune(req: AutoTuneRequest) -> dict: + """Détecte le GPU + le modèle, calcule les réglages optimaux, et (par + défaut) les applique. Renvoie le détail de la détection pour l'UI.""" + reco = await autotune.recommend(req.model) + placement = await autotune.placement(req.model) + + applied = None + if req.apply: + applied = agent_config.save_config( + { + "num_ctx": reco["recommended"]["num_ctx"], + "max_tokens": reco["recommended"]["max_tokens"], + } + ) + + return { + "detection": reco, + "placement": placement, + "config": applied or agent_config.get_config(), + "vram_override": settings.gpu_vram_mb, + } diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index e137bb4..ee59d24 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -35,6 +35,9 @@ services: - PORT=8717 # Optionnel : instance SearxNG pour l'outil web_search (sinon DuckDuckGo) - SEARX_URL= + # Optionnel : VRAM (Mo) de ton GPU pour l'auto-réglage, si Ollama est sur + # une autre machine (sinon laisse 0). Ex. 12000 pour une carte 12 Go. + - GPU_VRAM_MB=0 volumes: - /mnt/user/appdata/loki/workspace:/workspace - /mnt/user/appdata/loki/data:/data diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1f2c09f..eedc75e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -61,10 +61,39 @@ export interface AgentConfig { top_p: number; top_k: number; max_tokens: number; + num_ctx: number; tools: Record; confirm_shell: boolean; } +export interface AutoTuneResult { + detection: { + gpu: { available: boolean; name: string; vram_total_mb: number; source: string }; + model_profile: { + context_length: number | null; + parameter_size: string | null; + quantization: string | null; + size_mb: number | null; + }; + recommended: { num_ctx: number; max_tokens: number }; + rationale: string; + }; + placement: { loaded: boolean; where?: string; gpu_percent?: number }; + config: AgentConfig; +} + +export async function autoTune( + model: string, + apply = true +): Promise { + const res = await fetch("/api/config/auto", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, apply }), + }); + return res.json(); +} + export async function runShell( command: string ): Promise<{ command: string; exit_code: number; output: string }> { diff --git a/frontend/src/panels/SettingsView.tsx b/frontend/src/panels/SettingsView.tsx index 074fd38..be7c5c9 100644 --- a/frontend/src/panels/SettingsView.tsx +++ b/frontend/src/panels/SettingsView.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useStore } from "../store/useStore"; import { pullModel } from "../api/client"; -import type { AgentConfig } from "../api/client"; +import type { AgentConfig, AutoTuneResult } from "../api/client"; import { DownloadIcon, RefreshIcon } from "../components/Icon"; const TOOL_DESC: Record = { @@ -25,6 +25,9 @@ export function SettingsView() { availableTools, refreshConfig, updateConfig, + tuning, + tuneResult, + runAutoTune, } = useStore(); // Brouillon local édité, synchronisé depuis la config serveur. @@ -221,7 +224,25 @@ export function SettingsView() { {/* Génération + Outils */}
-
Génération
+
+
Génération
+ +
+ + {tuneResult && } + String(Math.round(v))} onChange={(v) => set("max_tokens", Math.round(v))} + /> + + v === 0 ? "auto" : v >= 1024 ? `${v / 1024}K` : String(v) + } + onChange={(v) => set("num_ctx", Math.round(v))} last />
@@ -352,6 +384,51 @@ export function SettingsView() { ); } +function TuneBanner({ result }: { result: AutoTuneResult }) { + const { gpu, model_profile, recommended, rationale } = result.detection; + const place = result.placement; + + const placeLabel = + place.loaded && place.where + ? place.where === "gpu" + ? `chargé GPU (${place.gpu_percent}%)` + : place.where === "cpu" + ? "chargé CPU ⚠️" + : `mixte GPU ${place.gpu_percent}%` + : "non chargé"; + const placeColor = + place.where === "gpu" ? "text-ok" : place.where === "cpu" ? "text-warn" : "text-muted"; + + return ( +
+
+ ⚡ Optimisé + {placeLabel} +
+
+ {gpu.available + ? `${gpu.name} · ${(gpu.vram_total_mb / 1024).toFixed(1)} Go VRAM` + : "Aucun GPU détecté"} + {model_profile.context_length + ? ` · ctx modèle ${Math.round(model_profile.context_length / 1024)}K` + : ""} +
+
+ → contexte {recommended.num_ctx} · jetons max{" "} + {recommended.max_tokens} +
+ {rationale &&
{rationale}
} + {!gpu.available && ( +
+ GPU non détecté dans le conteneur. Si Ollama tourne sur une autre + machine, déclare la VRAM via GPU_VRAM_MB (ex. 12000) dans + le compose pour un réglage précis. +
+ )} +
+ ); +} + function Slider({ label, value, diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index e7f229b..9877660 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { + autoTune, createSession, deleteSession, getConfig, @@ -13,6 +14,7 @@ import { saveConfig, streamChat, type AgentConfig, + type AutoTuneResult, type FileNode, type Message, type OllamaModel, @@ -43,6 +45,10 @@ interface LokiState { refreshConfig: () => Promise; updateConfig: (patch: Partial) => Promise; + tuning: boolean; + tuneResult: AutoTuneResult | null; + runAutoTune: () => Promise; + pendingShell: string | null; // commande shell en attente de validation approveShell: () => Promise; rejectShell: () => Promise; @@ -114,6 +120,21 @@ export const useStore = create((set, get) => ({ set({ config }); }, + tuning: false, + tuneResult: null, + + runAutoTune: async () => { + const model = get().selectedModel; + if (!model || get().tuning) return; + set({ tuning: true }); + try { + const result = await autoTune(model, true); + set({ tuneResult: result, config: result.config }); + } finally { + set({ tuning: false }); + } + }, + openPreview: async (path) => { const content = await fileContent(path); set({ previewPath: path, previewContent: content });