mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 14:08:11 +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 |
|
| Variable | Défaut | Rôle |
|
||||||
| --------------- | ----------------------------------- | ----------------------------- |
|
| --------------- | ----------------------------------- | ----------------------------- |
|
||||||
| `OLLAMA_HOST` | `http://host.docker.internal:11434` | URL de l'instance Ollama |
|
| `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 |
|
| `WORKSPACE_DIR` | `/workspace` | Dossier de travail de l'agent |
|
||||||
| `DATA_DIR` | `/data` | Base SQLite (sessions + config) |
|
| `DATA_DIR` | `/data` | Base SQLite (sessions + config) |
|
||||||
| `PORT` | `8717` | Port de l'application (dedans = dehors) |
|
| `PORT` | `8717` | Port de l'application (dedans = dehors) |
|
||||||
@@ -96,8 +96,7 @@ npm run dev # http://localhost:5173
|
|||||||
## Utilisation
|
## Utilisation
|
||||||
|
|
||||||
1. Vérifie la pastille **Ollama** (verte = connecté) en haut à droite, et choisis
|
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`,
|
un modèle qui supporte le *function calling* (profil fourni : `gemma4:12b`).
|
||||||
`qwen2.5-coder`).
|
|
||||||
2. Décris une tâche dans le tchat, p. ex. *« Crée une landing page pour un café
|
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 »*.
|
nommé Café Lumière, avec menu et horaires »*.
|
||||||
3. L'agent lit/écrit des fichiers dans le **workspace** ; chaque appel d'outil
|
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é |
|
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
|
||||||
| `run_shell` | Exécuter une commande **(sensible)** | 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** :
|
Loki est préconfiguré pour une **RTX 3060 12 Go** avec `gemma4:12b` en Q4 :
|
||||||
1. détecte le GPU (VRAM) via `nvidia-smi`/`rocm-smi`, ou la valeur déclarée
|
contexte 8192, sortie 4096 jetons, batch 256, GPU principal 0 et les 49 couches
|
||||||
`GPU_VRAM_MB` ;
|
du modèle sur le GPU. Les paramètres de génération et de cache/contexte sont
|
||||||
2. lit les métadonnées du modèle sélectionné via Ollama (contexte max,
|
enregistrés séparément pour chaque modèle.
|
||||||
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
|
La quantification du cache KV reste globale dans Ollama. Pour économiser environ
|
||||||
> détection `nvidia-smi` ne voit pas ce GPU. Déclare alors la VRAM avec
|
la moitié de sa VRAM, démarre Ollama avec `OLLAMA_FLASH_ATTENTION=1` et
|
||||||
> `GPU_VRAM_MB` (ex. `12000` pour une carte 12 Go) pour un réglage précis.
|
`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
|
||||||
> **Pourquoi c'est utile** : un `num_ctx` trop grand fait déborder le modèle sur
|
configuration Docker sans accès CUDA.
|
||||||
> 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é
|
## Sécurité
|
||||||
|
|
||||||
|
|||||||
+52
-2
@@ -22,6 +22,7 @@ from .ollama_client import OllamaError, ollama
|
|||||||
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
|
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
|
||||||
|
|
||||||
MAX_ITERATIONS = 6
|
MAX_ITERATIONS = 6
|
||||||
|
MAX_TOOL_REPAIR_ATTEMPTS = 2
|
||||||
|
|
||||||
|
|
||||||
def _tools_not_supported(exc: OllamaError) -> bool:
|
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
|
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:
|
def _parse_args(raw) -> dict:
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
return raw
|
return raw
|
||||||
@@ -62,6 +72,8 @@ async def run_agent(
|
|||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
active_tools = tools
|
active_tools = tools
|
||||||
tool_fallback_used = False
|
tool_fallback_used = False
|
||||||
|
tool_repair_attempts = 0
|
||||||
|
request_options = dict(options or {})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for _ in range(MAX_ITERATIONS):
|
for _ in range(MAX_ITERATIONS):
|
||||||
@@ -75,7 +87,11 @@ async def run_agent(
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
async for chunk in ollama.chat(
|
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", {})
|
msg = chunk.get("message", {})
|
||||||
token = msg.get("content", "")
|
token = msg.get("content", "")
|
||||||
@@ -94,6 +110,38 @@ async def run_agent(
|
|||||||
break
|
break
|
||||||
break
|
break
|
||||||
except OllamaError as exc:
|
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 (
|
if (
|
||||||
active_tools
|
active_tools
|
||||||
and not tool_fallback_used
|
and not tool_fallback_used
|
||||||
@@ -188,7 +236,9 @@ async def run_agent(
|
|||||||
if awaiting_confirmation:
|
if awaiting_confirmation:
|
||||||
# Laisse le modèle conclure son tour (message d'attente).
|
# Laisse le modèle conclure son tour (message d'attente).
|
||||||
final_chunk = ""
|
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", "")
|
tok = chunk.get("message", {}).get("content", "")
|
||||||
if tok:
|
if tok:
|
||||||
final_chunk += tok
|
final_chunk += tok
|
||||||
|
|||||||
+77
-13
@@ -8,12 +8,17 @@ from __future__ import annotations
|
|||||||
from . import db
|
from . import db
|
||||||
|
|
||||||
CONFIG_KEY = "agent"
|
CONFIG_KEY = "agent"
|
||||||
|
MODEL_PROFILES_KEY = "model_profiles"
|
||||||
|
PROFILE_STATE_KEY = "model_profiles_state"
|
||||||
|
PROFILE_VERSION = 3
|
||||||
|
|
||||||
DEFAULT_SYSTEM_PROMPT = (
|
DEFAULT_SYSTEM_PROMPT = (
|
||||||
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
|
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
|
||||||
"d'outils pour lire, écrire et lister des fichiers dans le workspace. "
|
"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 "
|
"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
|
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
|
||||||
@@ -28,24 +33,73 @@ DEFAULT_TOOL_STATE = {
|
|||||||
"run_shell": False,
|
"run_shell": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
GENERATION_FIELDS = {
|
||||||
"system_prompt": DEFAULT_SYSTEM_PROMPT,
|
"temperature",
|
||||||
|
"top_p",
|
||||||
|
"top_k",
|
||||||
|
"max_tokens",
|
||||||
|
"num_ctx",
|
||||||
|
"num_gpu",
|
||||||
|
"num_batch",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_GENERATION: dict = {
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"top_p": 0.9,
|
"top_p": 0.9,
|
||||||
"top_k": 40,
|
"top_k": 40,
|
||||||
"max_tokens": 2048,
|
"max_tokens": 2048,
|
||||||
# Fenêtre de contexte envoyée à Ollama (0 = laisser le défaut du modèle).
|
"num_ctx": 4096,
|
||||||
"num_ctx": 0,
|
"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),
|
"tools": dict(DEFAULT_TOOL_STATE),
|
||||||
# Demander une validation utilisateur avant toute commande shell.
|
# Demander une validation utilisateur avant toute commande shell.
|
||||||
"confirm_shell": True,
|
"confirm_shell": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> dict:
|
def _default_generation(model: str | None) -> dict:
|
||||||
"""Config courante = défauts fusionnés avec le stockage."""
|
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 {}
|
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"] = {
|
cfg["tools"] = {
|
||||||
name: bool(stored.get("tools", {}).get(name, DEFAULT_TOOL_STATE[name]))
|
name: bool(stored.get("tools", {}).get(name, DEFAULT_TOOL_STATE[name]))
|
||||||
for name in AVAILABLE_TOOLS
|
for name in AVAILABLE_TOOLS
|
||||||
@@ -53,9 +107,10 @@ def get_config() -> dict:
|
|||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def save_config(patch: dict) -> dict:
|
def save_config(patch: dict, model: str | None = None) -> dict:
|
||||||
"""Applique une mise à jour partielle et renvoie la config complète."""
|
"""Sauvegarde le comportement global et la génération par modèle."""
|
||||||
cfg = {**get_config(), **{k: v for k, v in patch.items() if v is not None}}
|
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"]:
|
if "tools" in patch and patch["tools"]:
|
||||||
cfg["tools"] = {
|
cfg["tools"] = {
|
||||||
name: bool(
|
name: bool(
|
||||||
@@ -65,8 +120,14 @@ def save_config(patch: dict) -> dict:
|
|||||||
)
|
)
|
||||||
for name in AVAILABLE_TOOLS
|
for name in AVAILABLE_TOOLS
|
||||||
}
|
}
|
||||||
db.set_config_value(CONFIG_KEY, cfg)
|
global_cfg = {k: v for k, v in cfg.items() if k not in GENERATION_FIELDS}
|
||||||
return get_config()
|
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:
|
def ollama_options(cfg: dict) -> dict:
|
||||||
@@ -76,6 +137,9 @@ def ollama_options(cfg: dict) -> dict:
|
|||||||
"top_p": cfg["top_p"],
|
"top_p": cfg["top_p"],
|
||||||
"top_k": cfg["top_k"],
|
"top_k": cfg["top_k"],
|
||||||
"num_predict": cfg["max_tokens"],
|
"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.
|
# num_ctx n'est envoyé que s'il est défini (> 0), sinon défaut du modèle.
|
||||||
if cfg.get("num_ctx"):
|
if cfg.get("num_ctx"):
|
||||||
|
|||||||
@@ -6,16 +6,11 @@ class Settings(BaseSettings):
|
|||||||
"""Réglages globaux de Loki (surchargés par variables d'environnement)."""
|
"""Réglages globaux de Loki (surchargés par variables d'environnement)."""
|
||||||
|
|
||||||
ollama_host: str = "http://host.docker.internal:11434"
|
ollama_host: str = "http://host.docker.internal:11434"
|
||||||
default_model: str = "llama3.1:8b"
|
default_model: str = "gemma4:12b"
|
||||||
workspace_dir: str = "/workspace"
|
workspace_dir: str = "/workspace"
|
||||||
data_dir: str = "/data"
|
data_dir: str = "/data"
|
||||||
port: int = 8080
|
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")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
raise HTTPException(404, "session introuvable")
|
raise HTTPException(404, "session introuvable")
|
||||||
|
|
||||||
model = req.model or session.get("model") or settings.default_model
|
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.
|
# Premier message : titre la session avec un extrait.
|
||||||
if not db.list_messages(req.session_id):
|
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 __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .. import agent_config, autotune
|
from .. import agent_config
|
||||||
from ..config import settings
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||||
|
|
||||||
@@ -17,48 +16,21 @@ class ConfigPatch(BaseModel):
|
|||||||
top_k: int | None = None
|
top_k: int | None = None
|
||||||
max_tokens: int | None = None
|
max_tokens: int | None = None
|
||||||
num_ctx: int | None = None
|
num_ctx: int | None = None
|
||||||
|
num_gpu: int | None = None
|
||||||
|
num_batch: int | None = None
|
||||||
tools: dict[str, bool] | None = None
|
tools: dict[str, bool] | None = None
|
||||||
confirm_shell: bool | None = None
|
confirm_shell: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class AutoTuneRequest(BaseModel):
|
|
||||||
model: str
|
|
||||||
apply: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_config() -> dict:
|
async def get_config(model: str | None = None) -> dict:
|
||||||
return {
|
return {
|
||||||
"config": agent_config.get_config(),
|
"config": agent_config.get_config(model),
|
||||||
"available_tools": agent_config.AVAILABLE_TOOLS,
|
"available_tools": agent_config.AVAILABLE_TOOLS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("")
|
||||||
async def put_config(patch: ConfigPatch) -> dict:
|
async def put_config(patch: ConfigPatch, model: str | None = None) -> dict:
|
||||||
cfg = agent_config.save_config(patch.model_dump(exclude_none=True))
|
cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model)
|
||||||
return {"config": cfg}
|
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}
|
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)
|
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)
|
os.makedirs(os.path.dirname(target) or _workspace_root(), exist_ok=True)
|
||||||
existed = os.path.isfile(target)
|
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)
|
f.write(content)
|
||||||
lines = len(content.splitlines())
|
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}
|
return {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +212,17 @@ TOOL_DEFINITIONS = [
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string", "description": "Chemin relatif au workspace"},
|
"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"],
|
"required": ["path", "content"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,15 +29,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# >>> À ADAPTER : IP de ton serveur Unraid (Ollama écoute sur :11434) <<<
|
# >>> À ADAPTER : IP de ton serveur Unraid (Ollama écoute sur :11434) <<<
|
||||||
- OLLAMA_HOST=http://192.168.1.10:11434
|
- OLLAMA_HOST=http://192.168.1.10:11434
|
||||||
- DEFAULT_MODEL=llama3.1:8b
|
- DEFAULT_MODEL=gemma4:12b
|
||||||
- WORKSPACE_DIR=/workspace
|
- WORKSPACE_DIR=/workspace
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
- PORT=8717
|
- PORT=8717
|
||||||
# Optionnel : instance SearxNG pour l'outil web_search (sinon DuckDuckGo)
|
# Optionnel : instance SearxNG pour l'outil web_search (sinon DuckDuckGo)
|
||||||
- SEARX_URL=
|
- 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:
|
volumes:
|
||||||
- /mnt/user/appdata/loki/workspace:/workspace
|
- /mnt/user/appdata/loki/workspace:/workspace
|
||||||
- /mnt/user/appdata/loki/data:/data
|
- /mnt/user/appdata/loki/data:/data
|
||||||
|
|||||||
+6
-1
@@ -7,7 +7,7 @@ services:
|
|||||||
- "${PORT:-8717}:${PORT:-8717}"
|
- "${PORT:-8717}:${PORT:-8717}"
|
||||||
environment:
|
environment:
|
||||||
- OLLAMA_HOST=${OLLAMA_HOST:-http://host.docker.internal:11434}
|
- 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
|
- WORKSPACE_DIR=/workspace
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
- PORT=${PORT:-8717}
|
- PORT=${PORT:-8717}
|
||||||
@@ -30,6 +30,11 @@ services:
|
|||||||
image: ollama/ollama:latest
|
image: ollama/ollama:latest
|
||||||
container_name: loki-ollama
|
container_name: loki-ollama
|
||||||
profiles: ["ollama"]
|
profiles: ["ollama"]
|
||||||
|
environment:
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
- OLLAMA_FLASH_ATTENTION=1
|
||||||
|
- OLLAMA_KV_CACHE_TYPE=q8_0
|
||||||
ports:
|
ports:
|
||||||
- "11434:11434"
|
- "11434:11434"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -62,38 +62,12 @@ export interface AgentConfig {
|
|||||||
top_k: number;
|
top_k: number;
|
||||||
max_tokens: number;
|
max_tokens: number;
|
||||||
num_ctx: number;
|
num_ctx: number;
|
||||||
|
num_gpu: number;
|
||||||
|
num_batch: number;
|
||||||
tools: Record<string, boolean>;
|
tools: Record<string, boolean>;
|
||||||
confirm_shell: 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(
|
export async function runShell(
|
||||||
command: string
|
command: string
|
||||||
): Promise<{ command: string; exit_code: number; output: string }> {
|
): Promise<{ command: string; exit_code: number; output: string }> {
|
||||||
@@ -105,18 +79,21 @@ export async function runShell(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getConfig(): Promise<{
|
export async function getConfig(model?: string): Promise<{
|
||||||
config: AgentConfig;
|
config: AgentConfig;
|
||||||
available_tools: string[];
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveConfig(
|
export async function saveConfig(
|
||||||
patch: Partial<AgentConfig>
|
patch: Partial<AgentConfig>,
|
||||||
|
model?: string
|
||||||
): Promise<AgentConfig> {
|
): Promise<AgentConfig> {
|
||||||
const res = await fetch("/api/config", {
|
const query = model ? `?model=${encodeURIComponent(model)}` : "";
|
||||||
|
const res = await fetch(`/api/config${query}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useStore } from "../store/useStore";
|
import { useStore } from "../store/useStore";
|
||||||
import { pullModel } from "../api/client";
|
import { pullModel } from "../api/client";
|
||||||
import type { AgentConfig, AutoTuneResult } from "../api/client";
|
import type { AgentConfig } from "../api/client";
|
||||||
import { DownloadIcon, RefreshIcon } from "../components/Icon";
|
import { DownloadIcon, RefreshIcon } from "../components/Icon";
|
||||||
|
|
||||||
const TOOL_DESC: Record<string, string> = {
|
const TOOL_DESC: Record<string, string> = {
|
||||||
@@ -25,9 +25,6 @@ export function SettingsView() {
|
|||||||
availableTools,
|
availableTools,
|
||||||
refreshConfig,
|
refreshConfig,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
tuning,
|
|
||||||
tuneResult,
|
|
||||||
runAutoTune,
|
|
||||||
} = useStore();
|
} = useStore();
|
||||||
|
|
||||||
// Brouillon local édité, synchronisé depuis la config serveur.
|
// Brouillon local édité, synchronisé depuis la config serveur.
|
||||||
@@ -36,7 +33,7 @@ export function SettingsView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshConfig();
|
refreshConfig();
|
||||||
}, [refreshConfig]);
|
}, [refreshConfig, selectedModel]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) setDraft(config);
|
if (config) setDraft(config);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
@@ -224,25 +221,12 @@ export function SettingsView() {
|
|||||||
{/* Génération + Outils */}
|
{/* Génération + Outils */}
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-1 text-sm font-bold">Génération</div>
|
||||||
<div className="text-sm font-bold">Génération</div>
|
<div className="mb-4 text-[11.5px] text-muted-2">
|
||||||
<button
|
Profil enregistré pour <b>{selectedModel || "ce modèle"}</b>.
|
||||||
onClick={runAutoTune}
|
Le contexte détermine directement la taille du cache KV.
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{tuneResult && <TuneBanner result={tuneResult} />}
|
|
||||||
|
|
||||||
<Slider
|
<Slider
|
||||||
label="Température"
|
label="Température"
|
||||||
value={draft.temperature}
|
value={draft.temperature}
|
||||||
@@ -280,17 +264,38 @@ export function SettingsView() {
|
|||||||
onChange={(v) => set("max_tokens", Math.round(v))}
|
onChange={(v) => set("max_tokens", Math.round(v))}
|
||||||
/>
|
/>
|
||||||
<Slider
|
<Slider
|
||||||
label="Contexte (num_ctx)"
|
label="Cache KV / contexte"
|
||||||
value={draft.num_ctx}
|
value={draft.num_ctx}
|
||||||
min={0}
|
min={2048}
|
||||||
max={32768}
|
max={32768}
|
||||||
step={1024}
|
step={1024}
|
||||||
fmt={(v) =>
|
fmt={(v) => `${Math.round(v / 1024)}K`}
|
||||||
v === 0 ? "auto" : v >= 1024 ? `${v / 1024}K` : String(v)
|
|
||||||
}
|
|
||||||
onChange={(v) => set("num_ctx", Math.round(v))}
|
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
|
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>
|
||||||
|
|
||||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
<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({
|
function Slider({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import {
|
import {
|
||||||
autoTune,
|
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
getConfig,
|
getConfig,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
saveConfig,
|
saveConfig,
|
||||||
streamChat,
|
streamChat,
|
||||||
type AgentConfig,
|
type AgentConfig,
|
||||||
type AutoTuneResult,
|
|
||||||
type FileNode,
|
type FileNode,
|
||||||
type Message,
|
type Message,
|
||||||
type OllamaModel,
|
type OllamaModel,
|
||||||
@@ -47,10 +45,6 @@ interface LokiState {
|
|||||||
refreshConfig: () => Promise<void>;
|
refreshConfig: () => Promise<void>;
|
||||||
updateConfig: (patch: Partial<AgentConfig>) => 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
|
pendingShell: string | null; // commande shell en attente de validation
|
||||||
approveShell: () => Promise<void>;
|
approveShell: () => Promise<void>;
|
||||||
rejectShell: () => Promise<void>;
|
rejectShell: () => Promise<void>;
|
||||||
@@ -115,36 +109,26 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
refreshConfig: async () => {
|
refreshConfig: async () => {
|
||||||
const { config, available_tools } = await getConfig();
|
const { config, available_tools } = await getConfig(
|
||||||
|
get().selectedModel || undefined
|
||||||
|
);
|
||||||
set({ config, availableTools: available_tools });
|
set({ config, availableTools: available_tools });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateConfig: async (patch) => {
|
updateConfig: async (patch) => {
|
||||||
const config = await saveConfig(patch);
|
const config = await saveConfig(patch, get().selectedModel || undefined);
|
||||||
set({ config });
|
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) => {
|
openPreview: async (path) => {
|
||||||
const content = await fileContent(path);
|
const content = await fileContent(path);
|
||||||
set({ previewPath: path, previewContent: content });
|
set({ previewPath: path, previewContent: content });
|
||||||
},
|
},
|
||||||
|
|
||||||
setSelectedModel: (name) => set({ selectedModel: name }),
|
setSelectedModel: (name) => {
|
||||||
|
set({ selectedModel: name });
|
||||||
|
void get().refreshConfig();
|
||||||
|
},
|
||||||
|
|
||||||
refreshFiles: async () => {
|
refreshFiles: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -175,6 +159,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
? def
|
? def
|
||||||
: models[0]?.name ?? "";
|
: models[0]?.name ?? "";
|
||||||
set({ models, selectedModel });
|
set({ models, selectedModel });
|
||||||
|
await get().refreshConfig();
|
||||||
} finally {
|
} finally {
|
||||||
set({ loadingModels: false });
|
set({ loadingModels: false });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user