mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Add per-model GPU and context profiles
This commit is contained in:
1 parent
cdbb5f03fa
commit
e24d397d31
12 files changed
+224
-215
No files matched your search
@@ -87,7 +87,7 @@ npm run dev # http://localhost:5173
|
||||
| Variable | Défaut | Rôle |
|
||||
| --------------- | ----------------------------------- | ----------------------------- |
|
||||
| `OLLAMA_HOST` | `http://host.docker.internal:11434` | URL de l'instance Ollama |
|
||||
| `DEFAULT_MODEL` | `llama3.1:8b` | Modèle sélectionné au démarrage |
|
||||
| `DEFAULT_MODEL` | `gemma4:12b` | Modèle sélectionné au démarrage |
|
||||
| `WORKSPACE_DIR` | `/workspace` | Dossier de travail de l'agent |
|
||||
| `DATA_DIR` | `/data` | Base SQLite (sessions + config) |
|
||||
| `PORT` | `8717` | Port de l'application (dedans = dehors) |
|
||||
@@ -96,8 +96,7 @@ npm run dev # http://localhost:5173
|
||||
## Utilisation
|
||||
|
||||
1. Vérifie la pastille **Ollama** (verte = connecté) en haut à droite, et choisis
|
||||
un modèle qui supporte le *function calling* (ex. `llama3.1:8b`,
|
||||
`qwen2.5-coder`).
|
||||
un modèle qui supporte le *function calling* (profil fourni : `gemma4:12b`).
|
||||
2. Décris une tâche dans le tchat, p. ex. *« Crée une landing page pour un café
|
||||
nommé Café Lumière, avec menu et horaires »*.
|
||||
3. L'agent lit/écrit des fichiers dans le **workspace** ; chaque appel d'outil
|
||||
@@ -116,25 +115,18 @@ 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 »)
|
||||
## Profil GPU fourni
|
||||
|
||||
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).
|
||||
Loki est préconfiguré pour une **RTX 3060 12 Go** avec `gemma4:12b` en Q4 :
|
||||
contexte 8192, sortie 4096 jetons, batch 256, GPU principal 0 et les 49 couches
|
||||
du modèle sur le GPU. Les paramètres de génération et de cache/contexte sont
|
||||
enregistrés séparément pour chaque modèle.
|
||||
|
||||
> **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.
|
||||
La quantification du cache KV reste globale dans Ollama. Pour économiser environ
|
||||
la moitié de sa VRAM, démarre Ollama avec `OLLAMA_FLASH_ATTENTION=1` et
|
||||
`OLLAMA_KV_CACHE_TYPE=q8_0`. Un redémarrage d'Ollama est requis. Le conteneur
|
||||
doit également exposer le GPU (`--gpus=all`) ; Loki ne peut pas contourner une
|
||||
configuration Docker sans accès CUDA.
|
||||
|
||||
## Sécurité
|
||||
|
||||
|
||||
+52
-2
@@ -22,6 +22,7 @@ from .ollama_client import OllamaError, ollama
|
||||
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
|
||||
|
||||
MAX_ITERATIONS = 6
|
||||
MAX_TOOL_REPAIR_ATTEMPTS = 2
|
||||
|
||||
|
||||
def _tools_not_supported(exc: OllamaError) -> bool:
|
||||
@@ -30,6 +31,15 @@ def _tools_not_supported(exc: OllamaError) -> bool:
|
||||
return "does not support tools" in message or "does not support tool" in message
|
||||
|
||||
|
||||
def _invalid_tool_arguments(exc: OllamaError) -> bool:
|
||||
message = str(exc).lower()
|
||||
return (
|
||||
"invalid tool call arguments" in message
|
||||
or "unexpected end of json" in message
|
||||
or "failed to parse tool" in message
|
||||
)
|
||||
|
||||
|
||||
def _parse_args(raw) -> dict:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
@@ -62,6 +72,8 @@ async def run_agent(
|
||||
text_parts: list[str] = []
|
||||
active_tools = tools
|
||||
tool_fallback_used = False
|
||||
tool_repair_attempts = 0
|
||||
request_options = dict(options or {})
|
||||
|
||||
try:
|
||||
for _ in range(MAX_ITERATIONS):
|
||||
@@ -75,7 +87,11 @@ async def run_agent(
|
||||
while True:
|
||||
try:
|
||||
async for chunk in ollama.chat(
|
||||
model, convo, tools=active_tools, options=options, stream=True
|
||||
model,
|
||||
convo,
|
||||
tools=active_tools,
|
||||
options=request_options,
|
||||
stream=True,
|
||||
):
|
||||
msg = chunk.get("message", {})
|
||||
token = msg.get("content", "")
|
||||
@@ -94,6 +110,38 @@ async def run_agent(
|
||||
break
|
||||
break
|
||||
except OllamaError as exc:
|
||||
if (
|
||||
active_tools
|
||||
and tool_repair_attempts < MAX_TOOL_REPAIR_ATTEMPTS
|
||||
and not content_buf
|
||||
and not tool_calls
|
||||
and _invalid_tool_arguments(exc)
|
||||
):
|
||||
tool_repair_attempts += 1
|
||||
request_options["num_predict"] = max(
|
||||
int(request_options.get("num_predict", 0)), 4096
|
||||
)
|
||||
thinking_buf = ""
|
||||
convo.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"L'appel d'outil précédent contenait un JSON "
|
||||
"tronqué. Réessaie immédiatement avec des arguments "
|
||||
"JSON valides. Pour un fichier long, utilise write_file "
|
||||
"en plusieurs appels : overwrite puis append, avec des "
|
||||
"morceaux courts et complets."
|
||||
),
|
||||
}
|
||||
)
|
||||
yield {
|
||||
"type": "notice",
|
||||
"message": (
|
||||
"Appel d'outil tronqué : nouvelle tentative "
|
||||
f"{tool_repair_attempts}/{MAX_TOOL_REPAIR_ATTEMPTS}."
|
||||
),
|
||||
}
|
||||
continue
|
||||
if (
|
||||
active_tools
|
||||
and not tool_fallback_used
|
||||
@@ -188,7 +236,9 @@ async def run_agent(
|
||||
if awaiting_confirmation:
|
||||
# Laisse le modèle conclure son tour (message d'attente).
|
||||
final_chunk = ""
|
||||
async for chunk in ollama.chat(model, convo, options=options, stream=True):
|
||||
async for chunk in ollama.chat(
|
||||
model, convo, options=request_options, stream=True
|
||||
):
|
||||
tok = chunk.get("message", {}).get("content", "")
|
||||
if tok:
|
||||
final_chunk += tok
|
||||
|
||||
+77
-13
@@ -8,12 +8,17 @@ from __future__ import annotations
|
||||
from . import db
|
||||
|
||||
CONFIG_KEY = "agent"
|
||||
MODEL_PROFILES_KEY = "model_profiles"
|
||||
PROFILE_STATE_KEY = "model_profiles_state"
|
||||
PROFILE_VERSION = 3
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = (
|
||||
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
|
||||
"d'outils pour lire, écrire et lister des fichiers dans le workspace. "
|
||||
"Utilise-les pour accomplir les tâches concrètement, puis réponds de façon "
|
||||
"concise en français. Après avoir écrit un fichier, propose un aperçu."
|
||||
"concise en français. Pour un fichier long, appelle write_file en plusieurs "
|
||||
"morceaux (overwrite puis append) afin de toujours produire un JSON valide. "
|
||||
"Après avoir écrit un fichier, propose un aperçu."
|
||||
)
|
||||
|
||||
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
|
||||
@@ -28,24 +33,73 @@ DEFAULT_TOOL_STATE = {
|
||||
"run_shell": False,
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"system_prompt": DEFAULT_SYSTEM_PROMPT,
|
||||
GENERATION_FIELDS = {
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"max_tokens",
|
||||
"num_ctx",
|
||||
"num_gpu",
|
||||
"num_batch",
|
||||
}
|
||||
|
||||
DEFAULT_GENERATION: dict = {
|
||||
"temperature": 0.7,
|
||||
"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,
|
||||
"num_ctx": 4096,
|
||||
"num_gpu": -1,
|
||||
"num_batch": 256,
|
||||
}
|
||||
|
||||
RTX_3060_GEMMA4_PROFILE: dict = {
|
||||
**DEFAULT_GENERATION,
|
||||
"max_tokens": 4096,
|
||||
"num_ctx": 8192,
|
||||
"num_gpu": 49,
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"system_prompt": DEFAULT_SYSTEM_PROMPT,
|
||||
**DEFAULT_GENERATION,
|
||||
"tools": dict(DEFAULT_TOOL_STATE),
|
||||
# Demander une validation utilisateur avant toute commande shell.
|
||||
"confirm_shell": True,
|
||||
}
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
"""Config courante = défauts fusionnés avec le stockage."""
|
||||
def _default_generation(model: str | None) -> dict:
|
||||
if model and model.split(":", 1)[0].lower() == "gemma4":
|
||||
return dict(RTX_3060_GEMMA4_PROFILE)
|
||||
return dict(DEFAULT_GENERATION)
|
||||
|
||||
|
||||
def _migrate_profiles() -> None:
|
||||
state = db.get_config_value(PROFILE_STATE_KEY) or {}
|
||||
if state.get("version", 0) >= PROFILE_VERSION:
|
||||
return
|
||||
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
|
||||
gemma_profile = {
|
||||
**RTX_3060_GEMMA4_PROFILE,
|
||||
**profiles.get("gemma4:12b", {}),
|
||||
}
|
||||
if gemma_profile.get("max_tokens", 0) <= 2048:
|
||||
gemma_profile["max_tokens"] = 4096
|
||||
profiles["gemma4:12b"] = gemma_profile
|
||||
db.set_config_value(MODEL_PROFILES_KEY, profiles)
|
||||
db.set_config_value(PROFILE_STATE_KEY, {"version": PROFILE_VERSION})
|
||||
|
||||
|
||||
def get_config(model: str | None = None) -> dict:
|
||||
"""Configuration globale complétée par le profil du modèle demandé."""
|
||||
_migrate_profiles()
|
||||
stored = db.get_config_value(CONFIG_KEY) or {}
|
||||
cfg = {**DEFAULT_CONFIG, **stored}
|
||||
global_stored = {k: v for k, v in stored.items() if k not in GENERATION_FIELDS}
|
||||
cfg = {**DEFAULT_CONFIG, **global_stored}
|
||||
if model:
|
||||
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
|
||||
cfg.update({**_default_generation(model), **profiles.get(model, {})})
|
||||
cfg["tools"] = {
|
||||
name: bool(stored.get("tools", {}).get(name, DEFAULT_TOOL_STATE[name]))
|
||||
for name in AVAILABLE_TOOLS
|
||||
@@ -53,9 +107,10 @@ def get_config() -> dict:
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(patch: dict) -> dict:
|
||||
"""Applique une mise à jour partielle et renvoie la config complète."""
|
||||
cfg = {**get_config(), **{k: v for k, v in patch.items() if v is not None}}
|
||||
def save_config(patch: dict, model: str | None = None) -> dict:
|
||||
"""Sauvegarde le comportement global et la génération par modèle."""
|
||||
clean = {k: v for k, v in patch.items() if v is not None}
|
||||
cfg = {**get_config(model), **clean}
|
||||
if "tools" in patch and patch["tools"]:
|
||||
cfg["tools"] = {
|
||||
name: bool(
|
||||
@@ -65,8 +120,14 @@ def save_config(patch: dict) -> dict:
|
||||
)
|
||||
for name in AVAILABLE_TOOLS
|
||||
}
|
||||
db.set_config_value(CONFIG_KEY, cfg)
|
||||
return get_config()
|
||||
global_cfg = {k: v for k, v in cfg.items() if k not in GENERATION_FIELDS}
|
||||
db.set_config_value(CONFIG_KEY, global_cfg)
|
||||
|
||||
if model:
|
||||
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
|
||||
profiles[model] = {field: cfg[field] for field in GENERATION_FIELDS}
|
||||
db.set_config_value(MODEL_PROFILES_KEY, profiles)
|
||||
return get_config(model)
|
||||
|
||||
|
||||
def ollama_options(cfg: dict) -> dict:
|
||||
@@ -76,6 +137,9 @@ def ollama_options(cfg: dict) -> dict:
|
||||
"top_p": cfg["top_p"],
|
||||
"top_k": cfg["top_k"],
|
||||
"num_predict": cfg["max_tokens"],
|
||||
"num_gpu": cfg["num_gpu"],
|
||||
"main_gpu": 0,
|
||||
"num_batch": cfg["num_batch"],
|
||||
}
|
||||
# num_ctx n'est envoyé que s'il est défini (> 0), sinon défaut du modèle.
|
||||
if cfg.get("num_ctx"):
|
||||
|
||||
@@ -6,16 +6,11 @@ class Settings(BaseSettings):
|
||||
"""Réglages globaux de Loki (surchargés par variables d'environnement)."""
|
||||
|
||||
ollama_host: str = "http://host.docker.internal:11434"
|
||||
default_model: str = "llama3.1:8b"
|
||||
default_model: str = "gemma4:12b"
|
||||
workspace_dir: str = "/workspace"
|
||||
data_dir: str = "/data"
|
||||
port: int = 8080
|
||||
|
||||
# Override manuel de la VRAM (Mo) si la détection GPU échoue dans le
|
||||
# conteneur (utile sur Unraid où le GPU est sur l'hôte / un autre conteneur).
|
||||
gpu_vram_mb: int = 0
|
||||
gpu_name: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
raise HTTPException(404, "session introuvable")
|
||||
|
||||
model = req.model or session.get("model") or settings.default_model
|
||||
cfg = agent_config.get_config()
|
||||
cfg = agent_config.get_config(model)
|
||||
|
||||
# Premier message : titre la session avec un extrait.
|
||||
if not db.list_messages(req.session_id):
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Routes de configuration de l'agent (lecture / mise à jour / auto-réglage)."""
|
||||
"""Routes de lecture et mise à jour de la configuration de l'agent."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import agent_config, autotune
|
||||
from ..config import settings
|
||||
from .. import agent_config
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
|
||||
@@ -17,48 +16,21 @@ class ConfigPatch(BaseModel):
|
||||
top_k: int | None = None
|
||||
max_tokens: int | None = None
|
||||
num_ctx: int | None = None
|
||||
num_gpu: int | None = None
|
||||
num_batch: int | None = None
|
||||
tools: dict[str, bool] | None = None
|
||||
confirm_shell: bool | None = None
|
||||
|
||||
|
||||
class AutoTuneRequest(BaseModel):
|
||||
model: str
|
||||
apply: bool = True
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_config() -> dict:
|
||||
async def get_config(model: str | None = None) -> dict:
|
||||
return {
|
||||
"config": agent_config.get_config(),
|
||||
"config": agent_config.get_config(model),
|
||||
"available_tools": agent_config.AVAILABLE_TOOLS,
|
||||
}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def put_config(patch: ConfigPatch) -> dict:
|
||||
cfg = agent_config.save_config(patch.model_dump(exclude_none=True))
|
||||
async def put_config(patch: ConfigPatch, model: str | None = None) -> dict:
|
||||
cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model)
|
||||
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,
|
||||
}
|
||||
+16
-4
@@ -50,14 +50,16 @@ def read_file(path: str) -> dict:
|
||||
return {"ok": True, "content": content, "summary": summary}
|
||||
|
||||
|
||||
def write_file(path: str, content: str) -> dict:
|
||||
def write_file(path: str, content: str, mode: str = "overwrite") -> dict:
|
||||
target = _safe_path(path)
|
||||
if mode not in {"overwrite", "append"}:
|
||||
raise ToolError("mode write_file invalide : utilise overwrite ou append")
|
||||
os.makedirs(os.path.dirname(target) or _workspace_root(), exist_ok=True)
|
||||
existed = os.path.isfile(target)
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
with open(target, "a" if mode == "append" else "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
lines = len(content.splitlines())
|
||||
verb = "modifié" if existed else "écrit"
|
||||
verb = "complété" if mode == "append" else "modifié" if existed else "écrit"
|
||||
return {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
|
||||
|
||||
|
||||
@@ -210,7 +212,17 @@ TOOL_DEFINITIONS = [
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Chemin relatif au workspace"},
|
||||
"content": {"type": "string", "description": "Contenu complet du fichier"},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Contenu complet ou morceau court du fichier",
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["overwrite", "append"],
|
||||
"description": (
|
||||
"overwrite pour le premier morceau, append pour les suivants"
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
},
|
||||
|
||||
@@ -29,15 +29,12 @@ services:
|
||||
environment:
|
||||
# >>> À ADAPTER : IP de ton serveur Unraid (Ollama écoute sur :11434) <<<
|
||||
- OLLAMA_HOST=http://192.168.1.10:11434
|
||||
- DEFAULT_MODEL=llama3.1:8b
|
||||
- DEFAULT_MODEL=gemma4:12b
|
||||
- WORKSPACE_DIR=/workspace
|
||||
- DATA_DIR=/data
|
||||
- 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
|
||||
|
||||
+6
-1
@@ -7,7 +7,7 @@ services:
|
||||
- "${PORT:-8717}:${PORT:-8717}"
|
||||
environment:
|
||||
- OLLAMA_HOST=${OLLAMA_HOST:-http://host.docker.internal:11434}
|
||||
- DEFAULT_MODEL=${DEFAULT_MODEL:-llama3.1:8b}
|
||||
- DEFAULT_MODEL=${DEFAULT_MODEL:-gemma4:12b}
|
||||
- WORKSPACE_DIR=/workspace
|
||||
- DATA_DIR=/data
|
||||
- PORT=${PORT:-8717}
|
||||
@@ -30,6 +30,11 @@ services:
|
||||
image: ollama/ollama:latest
|
||||
container_name: loki-ollama
|
||||
profiles: ["ollama"]
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
- OLLAMA_FLASH_ATTENTION=1
|
||||
- OLLAMA_KV_CACHE_TYPE=q8_0
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
|
||||
@@ -62,38 +62,12 @@ export interface AgentConfig {
|
||||
top_k: number;
|
||||
max_tokens: number;
|
||||
num_ctx: number;
|
||||
num_gpu: number;
|
||||
num_batch: number;
|
||||
tools: Record<string, boolean>;
|
||||
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<AutoTuneResult> {
|
||||
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 }> {
|
||||
@@ -105,18 +79,21 @@ export async function runShell(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<{
|
||||
export async function getConfig(model?: string): Promise<{
|
||||
config: AgentConfig;
|
||||
available_tools: string[];
|
||||
}> {
|
||||
const res = await fetch("/api/config");
|
||||
const query = model ? `?model=${encodeURIComponent(model)}` : "";
|
||||
const res = await fetch(`/api/config${query}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function saveConfig(
|
||||
patch: Partial<AgentConfig>
|
||||
patch: Partial<AgentConfig>,
|
||||
model?: string
|
||||
): Promise<AgentConfig> {
|
||||
const res = await fetch("/api/config", {
|
||||
const query = model ? `?model=${encodeURIComponent(model)}` : "";
|
||||
const res = await fetch(`/api/config${query}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStore } from "../store/useStore";
|
||||
import { pullModel } from "../api/client";
|
||||
import type { AgentConfig, AutoTuneResult } from "../api/client";
|
||||
import type { AgentConfig } from "../api/client";
|
||||
import { DownloadIcon, RefreshIcon } from "../components/Icon";
|
||||
|
||||
const TOOL_DESC: Record<string, string> = {
|
||||
@@ -25,9 +25,6 @@ export function SettingsView() {
|
||||
availableTools,
|
||||
refreshConfig,
|
||||
updateConfig,
|
||||
tuning,
|
||||
tuneResult,
|
||||
runAutoTune,
|
||||
} = useStore();
|
||||
|
||||
// Brouillon local édité, synchronisé depuis la config serveur.
|
||||
@@ -36,7 +33,7 @@ export function SettingsView() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshConfig();
|
||||
}, [refreshConfig]);
|
||||
}, [refreshConfig, selectedModel]);
|
||||
useEffect(() => {
|
||||
if (config) setDraft(config);
|
||||
}, [config]);
|
||||
@@ -224,25 +221,12 @@ export function SettingsView() {
|
||||
{/* Génération + Outils */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm font-bold">Génération</div>
|
||||
<button
|
||||
onClick={runAutoTune}
|
||||
disabled={tuning || !selectedModel}
|
||||
className="flex h-[26px] items-center gap-1.5 rounded-[7px] border border-[rgba(240,161,92,.4)] bg-[rgba(240,161,92,.10)] px-2.5 text-[11.5px] font-semibold text-accent disabled:opacity-40"
|
||||
title="Détecte le GPU et le modèle, puis optimise le contexte et les jetons"
|
||||
>
|
||||
{tuning ? (
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-accent/40 border-t-accent" />
|
||||
) : (
|
||||
<span>⚡</span>
|
||||
)}
|
||||
{tuning ? "Détection…" : "Réglage auto"}
|
||||
</button>
|
||||
<div className="mb-1 text-sm font-bold">Génération</div>
|
||||
<div className="mb-4 text-[11.5px] text-muted-2">
|
||||
Profil enregistré pour <b>{selectedModel || "ce modèle"}</b>.
|
||||
Le contexte détermine directement la taille du cache KV.
|
||||
</div>
|
||||
|
||||
{tuneResult && <TuneBanner result={tuneResult} />}
|
||||
|
||||
<Slider
|
||||
label="Température"
|
||||
value={draft.temperature}
|
||||
@@ -280,17 +264,38 @@ export function SettingsView() {
|
||||
onChange={(v) => set("max_tokens", Math.round(v))}
|
||||
/>
|
||||
<Slider
|
||||
label="Contexte (num_ctx)"
|
||||
label="Cache KV / contexte"
|
||||
value={draft.num_ctx}
|
||||
min={0}
|
||||
min={2048}
|
||||
max={32768}
|
||||
step={1024}
|
||||
fmt={(v) =>
|
||||
v === 0 ? "auto" : v >= 1024 ? `${v / 1024}K` : String(v)
|
||||
}
|
||||
fmt={(v) => `${Math.round(v / 1024)}K`}
|
||||
onChange={(v) => set("num_ctx", Math.round(v))}
|
||||
/>
|
||||
<Slider
|
||||
label="Couches GPU"
|
||||
value={draft.num_gpu}
|
||||
min={-1}
|
||||
max={120}
|
||||
step={1}
|
||||
fmt={(v) => (v < 0 ? "auto" : String(Math.round(v)))}
|
||||
onChange={(v) => set("num_gpu", Math.round(v))}
|
||||
/>
|
||||
<Slider
|
||||
label="Batch"
|
||||
value={draft.num_batch}
|
||||
min={64}
|
||||
max={1024}
|
||||
step={64}
|
||||
fmt={(v) => String(Math.round(v))}
|
||||
onChange={(v) => set("num_batch", Math.round(v))}
|
||||
last
|
||||
/>
|
||||
<div className="mt-3 rounded-lg border border-line-soft bg-base px-3 py-2 text-[11px] leading-relaxed text-muted-2">
|
||||
La précision KV (<code>f16</code>/<code>q8_0</code>) est un
|
||||
réglage global du serveur Ollama et nécessite son redémarrage.
|
||||
Les valeurs ci-dessus sont, elles, propres à chaque modèle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||
@@ -384,51 +389,6 @@ 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 (
|
||||
<div className="mb-4 rounded-[10px] border border-[rgba(240,161,92,.35)] bg-[rgba(240,161,92,.06)] p-3 text-[11.5px]">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="font-semibold text-accent">⚡ Optimisé</span>
|
||||
<span className={`font-mono ${placeColor}`}>{placeLabel}</span>
|
||||
</div>
|
||||
<div className="font-mono text-muted">
|
||||
{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`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-2">
|
||||
→ contexte <b className="text-ink-2">{recommended.num_ctx}</b> · jetons max{" "}
|
||||
<b className="text-ink-2">{recommended.max_tokens}</b>
|
||||
</div>
|
||||
{rationale && <div className="mt-1 text-muted-3">{rationale}</div>}
|
||||
{!gpu.available && (
|
||||
<div className="mt-1.5 text-muted-3">
|
||||
GPU non détecté dans le conteneur. Si Ollama tourne sur une autre
|
||||
machine, déclare la VRAM via <code>GPU_VRAM_MB</code> (ex. 12000) dans
|
||||
le compose pour un réglage précis.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Slider({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
autoTune,
|
||||
createSession,
|
||||
deleteSession,
|
||||
getConfig,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
saveConfig,
|
||||
streamChat,
|
||||
type AgentConfig,
|
||||
type AutoTuneResult,
|
||||
type FileNode,
|
||||
type Message,
|
||||
type OllamaModel,
|
||||
@@ -47,10 +45,6 @@ interface LokiState {
|
||||
refreshConfig: () => Promise<void>;
|
||||
updateConfig: (patch: Partial<AgentConfig>) => Promise<void>;
|
||||
|
||||
tuning: boolean;
|
||||
tuneResult: AutoTuneResult | null;
|
||||
runAutoTune: () => Promise<void>;
|
||||
|
||||
pendingShell: string | null; // commande shell en attente de validation
|
||||
approveShell: () => Promise<void>;
|
||||
rejectShell: () => Promise<void>;
|
||||
@@ -115,36 +109,26 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
},
|
||||
|
||||
refreshConfig: async () => {
|
||||
const { config, available_tools } = await getConfig();
|
||||
const { config, available_tools } = await getConfig(
|
||||
get().selectedModel || undefined
|
||||
);
|
||||
set({ config, availableTools: available_tools });
|
||||
},
|
||||
|
||||
updateConfig: async (patch) => {
|
||||
const config = await saveConfig(patch);
|
||||
const config = await saveConfig(patch, get().selectedModel || undefined);
|
||||
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 });
|
||||
},
|
||||
|
||||
setSelectedModel: (name) => set({ selectedModel: name }),
|
||||
setSelectedModel: (name) => {
|
||||
set({ selectedModel: name });
|
||||
void get().refreshConfig();
|
||||
},
|
||||
|
||||
refreshFiles: async () => {
|
||||
try {
|
||||
@@ -175,6 +159,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
? def
|
||||
: models[0]?.name ?? "";
|
||||
set({ models, selectedModel });
|
||||
await get().refreshConfig();
|
||||
} finally {
|
||||
set({ loadingModels: false });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user