diff --git a/README.md b/README.md index 35fe137..01c3d91 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,38 @@ message suivant paie alors un rechargement complet (lent). Loki évite ça : - **Indicateur d'état** : la pastille du sélecteur de modèle est verte quand le modèle est chargé sur GPU, orange sur CPU, blanche s'il reste à charger. +## Performance Ollama (recommandé) + +Réglages **côté serveur Ollama** qui rendent Loki nettement plus fluide +(variables d'environnement du service/conteneur Ollama, redémarrage requis) : + +| Variable | Valeur conseillée | Effet | +| -------- | ----------------- | ----- | +| `OLLAMA_KEEP_ALIVE` | `30m` (ou `-1`) | Durée de rétention par défaut d'un modèle en VRAM. `-1` = jamais déchargé (machine dédiée). Doit être ≥ au `keep_alive` configuré dans Loki. | +| `OLLAMA_MAX_LOADED_MODELS` | `2` | Autorise le modèle de chat **et** le modèle d'embedding (RAG) à résider ensemble en VRAM — supprime les allers-retours de chargement à chaque message. | +| `OLLAMA_NUM_PARALLEL` | `1` | Loki est mono-utilisateur ; chaque slot parallèle multiplie la VRAM du cache KV. | +| `OLLAMA_FLASH_ATTENTION` | `1` | Attention plus rapide et plus sobre en VRAM. | +| `OLLAMA_KV_CACHE_TYPE` | `q8_0` | Cache KV quantifié : ~moitié de VRAM en moins, marge pour un 12B sur 12 Go. | + +Exemple pour un Ollama en Docker (service `ollama` du compose) : + +```yaml + ollama: + image: ollama/ollama + environment: + - OLLAMA_KEEP_ALIVE=30m + - OLLAMA_MAX_LOADED_MODELS=2 + - OLLAMA_NUM_PARALLEL=1 + - OLLAMA_FLASH_ATTENTION=1 + - OLLAMA_KV_CACHE_TYPE=q8_0 +``` + +Côté Loki, tout est déjà optimisé : pool HTTP keep-alive partagé vers Ollama, +options runner identiques sur tous les appels (pas de rechargement du modèle en +plein message), `keep_alive` systématique (chat **et** embeddings), préparation +du contexte (RAG + plan) en parallèle après l'ouverture du flux, et caches +courts (`/api/tags`, stats GPU). + ## Modes d'exécution (Plan / Build / Yolo) Un sélecteur dans le composer contrôle le niveau d'autonomie de l'agent : @@ -188,9 +220,9 @@ efficaces même avec de petits modèles), repo map, et **commits git automatiques** dans le workspace. **C'est invisible** : un routeur classe chaque message. -- Tâche de code détectée (heuristique + micro-classification LLM sur les cas - ambigus) → le message part au **moteur code** ; le fil affiche la carte - `code_task`, les fichiers modifiés et le commit. +- Tâche de code détectée (heuristique lexicale instantanée) → le message part + au **moteur code** ; le fil affiche la carte `code_task`, les fichiers + modifiés et le commit. - Sinon → **boucle agent** classique ; et l'agent peut lui-même déléguer au moteur via l'outil `code_task` quand il juge qu'il faut coder. diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py index 8b2ffd4..522ee9e 100644 --- a/backend/app/agent_config.py +++ b/backend/app/agent_config.py @@ -179,15 +179,15 @@ def save_config(patch: dict, model: str | None = None) -> dict: return get_config(model) -def ollama_options(cfg: dict) -> dict: - """Traduit la config en options de génération Ollama.""" - opts = { - "temperature": cfg["temperature"], - "top_p": cfg["top_p"], - "top_k": cfg["top_k"], - "num_predict": cfg["max_tokens"], - "num_batch": cfg["num_batch"], - } +def runner_options(cfg: dict) -> dict: + """Sous-ensemble d'options qui détermine l'identité du runner Ollama. + + Ollama choisit son runner (processus de chargement du modèle) d'après + num_ctx / num_batch / num_gpu. TOUT appel au même modèle (plan, résumé, + agent…) doit envoyer ces mêmes valeurs, sinon Ollama recharge le modèle en + plein milieu d'un message — la cause principale des lenteurs observées. + """ + opts: dict = {"num_batch": cfg["num_batch"]} # num_gpu n'est transmis que si l'utilisateur force explicitement un nombre # de couches (≥ 0). En -1 (défaut), on laisse Ollama auto-ajuster l'offload # GPU comme `ollama run` ; lui imposer une valeur peut le forcer sur le CPU. @@ -200,5 +200,16 @@ def ollama_options(cfg: dict) -> dict: return opts +def ollama_options(cfg: dict) -> dict: + """Traduit la config en options de génération Ollama.""" + return { + **runner_options(cfg), + "temperature": cfg["temperature"], + "top_p": cfg["top_p"], + "top_k": cfg["top_k"], + "num_predict": cfg["max_tokens"], + } + + def enabled_tool_names(cfg: dict) -> list[str]: return [name for name, on in cfg["tools"].items() if on] diff --git a/backend/app/coder.py b/backend/app/coder.py index de5982c..c10f943 100644 --- a/backend/app/coder.py +++ b/backend/app/coder.py @@ -59,7 +59,7 @@ async def pick_code_model(current: str, preference: str | None = None) -> str: from .ollama_client import ollama try: - installed = await ollama.list_models() + installed = await ollama.list_models_cached() except Exception: return current diff --git a/backend/app/enhance.py b/backend/app/enhance.py index bebb9bd..921051d 100644 --- a/backend/app/enhance.py +++ b/backend/app/enhance.py @@ -46,13 +46,28 @@ def needs_plan(message: str) -> bool: return len(message) > 240 or connectors >= 2 -async def _ask(model: str, system: str, user: str, *, num_predict: int) -> str: +async def _ask( + model: str, + system: str, + user: str, + *, + num_predict: int, + options: dict | None = None, + keep_alive: str | None = None, +) -> str: + """Appel court au modèle. + + ``options`` doit contenir les options runner (num_ctx, num_batch…) du chat + principal : un appel avec des options divergentes force Ollama à recharger + le modèle en plein message. + """ text = "" async for chunk in ollama.chat( model, [{"role": "system", "content": system}, {"role": "user", "content": user}], - options={"temperature": 0.2, "num_predict": num_predict}, + options={**(options or {}), "temperature": 0.2, "num_predict": num_predict}, + keep_alive=keep_alive, stream=True, ): text += chunk.get("message", {}).get("content", "") @@ -61,10 +76,19 @@ async def _ask(model: str, system: str, user: str, *, num_predict: int) -> str: return text.strip() -async def make_plan(model: str, message: str) -> list[str]: +async def make_plan( + model: str, + message: str, + *, + options: dict | None = None, + keep_alive: str | None = None, +) -> list[str]: """Renvoie la liste des étapes (vide si échec — jamais bloquant).""" try: - raw = await _ask(model, _PLAN_PROMPT, message[:1200], num_predict=220) + raw = await _ask( + model, _PLAN_PROMPT, message[:1200], num_predict=220, + options=options, keep_alive=keep_alive, + ) except (httpx.HTTPError, OSError) as exc: logger.warning("Plan impossible : %s", exc) return [] @@ -77,7 +101,14 @@ async def make_plan(model: str, message: str) -> list[str]: return steps[:5] if len(steps) >= 2 else [] -async def self_review(model: str, request: str, answer: str) -> str | None: +async def self_review( + model: str, + request: str, + answer: str, + *, + options: dict | None = None, + keep_alive: str | None = None, +) -> str | None: """Critique puis révise la réponse. None si rien à corriger / échec.""" if len(answer) < 80: return None @@ -87,6 +118,7 @@ async def self_review(model: str, request: str, answer: str) -> str | None: _CRITIQUE_PROMPT, f"Demande :\n{request[:800]}\n\nRéponse :\n{answer[:2500]}", num_predict=180, + options=options, keep_alive=keep_alive, ) if not critique or "PARFAIT" in critique.upper()[:40]: return None @@ -97,6 +129,7 @@ async def self_review(model: str, request: str, answer: str) -> str | None: f"Demande :\n{request[:800]}\n\nRéponse initiale :\n{answer[:2500]}" f"\n\nDéfauts :\n{critique[:600]}", num_predict=1500, + options=options, keep_alive=keep_alive, ) # Garde-fou : une révision vide ou minuscule ne remplace rien. return revised if len(revised) > len(answer) // 3 else None diff --git a/backend/app/main.py b/backend/app/main.py index aa94cb0..70ad050 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles from . import agent_config, coder, db, rag from .config import settings +from .ollama_client import ollama from .routes import benchmark, chat, config, files, git, models, sessions, shell, system @@ -48,6 +49,8 @@ async def lifespan(_: FastAPI): # Préchargement du modèle par défaut, sans bloquer le démarrage. asyncio.create_task(_warm_default_model()) yield + # Ferme le pool HTTP partagé vers Ollama. + await ollama.aclose() app = FastAPI( diff --git a/backend/app/memory.py b/backend/app/memory.py index b68c488..a6d3ea0 100644 --- a/backend/app/memory.py +++ b/backend/app/memory.py @@ -52,8 +52,18 @@ def build_convo(sid: str, system_prompt: str) -> list[dict]: return convo -async def maybe_summarize(sid: str, model: str) -> None: - """Compresse les anciens tours dans le résumé (tâche d'arrière-plan).""" +async def maybe_summarize( + sid: str, + model: str, + *, + options: dict | None = None, + keep_alive: str | None = None, +) -> None: + """Compresse les anciens tours dans le résumé (tâche d'arrière-plan). + + ``options`` doit reprendre les options runner du chat (num_ctx…) pour ne + pas déclencher un rechargement du modèle après chaque réponse. + """ try: messages = db.list_messages_for_model(sid) if len(messages) <= SUMMARIZE_AFTER: @@ -76,7 +86,8 @@ async def maybe_summarize(sid: str, model: str) -> None: {"role": "system", "content": _SUMMARY_PROMPT}, {"role": "user", "content": transcript}, ], - options={"temperature": 0.2, "num_predict": 350}, + options={**(options or {}), "temperature": 0.2, "num_predict": 350}, + keep_alive=keep_alive, stream=True, ): text += chunk.get("message", {}).get("content", "") diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py index e93233e..f7b7135 100644 --- a/backend/app/ollama_client.py +++ b/backend/app/ollama_client.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio import json +import time from typing import AsyncIterator import httpx @@ -28,6 +29,12 @@ class OllamaError(RuntimeError): # une génération longue (ou un chargement de modèle sur CPU) ne doit pas couper. _STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0) +# Un chat déclenche plusieurs appels Ollama (routage, embed, plan, agent…) : +# un pool keep-alive partagé évite un handshake TCP à chaque appel. +_LIMITS = httpx.Limits( + max_connections=20, max_keepalive_connections=10, keepalive_expiry=30.0 +) + async def _raise_for_stream_status(resp: httpx.Response) -> None: """Lève une ``OllamaError`` détaillée si la réponse streaming est en erreur. @@ -51,72 +58,118 @@ class OllamaClient: def __init__(self, host: str | None = None) -> None: self.host = (host or settings.ollama_host).rstrip("/") + self._client: httpx.AsyncClient | None = None + # Cache court de /api/tags : la liste des modèles installés change + # rarement mais est consultée par plusieurs modules à chaque message. + self._tags_at = 0.0 + self._tags: list[dict] = [] + + def _http(self) -> httpx.AsyncClient: + """Client partagé (pool keep-alive), créé paresseusement. + + Un seul worker uvicorn / une seule boucle : la création lazy est sûre. + Le garde ``is_closed`` recrée le client si un arrêt l'a fermé. + """ + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + timeout=_STREAM_TIMEOUT, follow_redirects=True, limits=_LIMITS + ) + return self._client + + async def aclose(self) -> None: + """Ferme le pool partagé (appelé au shutdown de l'app).""" + if self._client is not None and not self._client.is_closed: + await self._client.aclose() async def ping(self) -> dict: """Vérifie la connexion et renvoie la version d'Ollama.""" - async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client: - resp = await client.get(f"{self.host}/api/version") - resp.raise_for_status() - return resp.json() + resp = await self._http().get(f"{self.host}/api/version", timeout=5.0) + resp.raise_for_status() + return resp.json() async def list_models(self) -> list[dict]: - """Liste les modèles installés localement (/api/tags).""" - async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: - resp = await client.get(f"{self.host}/api/tags") - resp.raise_for_status() - return resp.json().get("models", []) + """Liste les modèles installés localement (/api/tags), sans cache.""" + resp = await self._http().get(f"{self.host}/api/tags", timeout=10.0) + resp.raise_for_status() + models = resp.json().get("models", []) + self._tags, self._tags_at = models, time.monotonic() + return models - async def embed(self, model: str, texts: list[str]) -> list[list[float]]: - """Vecteurs d'embedding pour une liste de textes (/api/embed).""" - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: - resp = await client.post( - f"{self.host}/api/embed", json={"model": model, "input": texts} - ) - resp.raise_for_status() - return resp.json().get("embeddings", []) + async def list_models_cached(self, ttl: float = 30.0) -> list[dict]: + """Comme ``list_models`` mais avec un cache court partagé. + + Utilisé par les chemins chauds (routage code, résolution embed, route + /api/models) pour ne pas marteler /api/tags à chaque message. + """ + if self._tags and time.monotonic() - self._tags_at < ttl: + return self._tags + return await self.list_models() + + def invalidate_tags_cache(self) -> None: + """Force un rafraîchissement après un pull ou une suppression de modèle.""" + self._tags_at = 0.0 + self._tags = [] + + async def embed( + self, model: str, texts: list[str], keep_alive: str = "30m" + ) -> list[list[float]]: + """Vecteurs d'embedding pour une liste de textes (/api/embed). + + ``keep_alive`` long : le modèle d'embedding est minuscule (<0,5 Go) et + sollicité à chaque message (recall + indexation) — le laisser chargé + évite un aller-retour VRAM permanent avec le modèle de chat. + """ + resp = await self._http().post( + f"{self.host}/api/embed", + json={"model": model, "input": texts, "keep_alive": keep_alive}, + timeout=30.0, + ) + resp.raise_for_status() + return resp.json().get("embeddings", []) 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", []) + resp = await self._http().get(f"{self.host}/api/ps", timeout=5.0) + 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: - resp = await client.post(f"{self.host}/api/show", json={"name": name}) - resp.raise_for_status() - return resp.json() + resp = await self._http().post( + f"{self.host}/api/show", json={"name": name}, timeout=15.0 + ) + resp.raise_for_status() + return resp.json() async def delete_model(self, name: str) -> dict: """Supprime un modèle installé (/api/delete).""" - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: - resp = await client.request( - "DELETE", f"{self.host}/api/delete", json={"model": name} - ) - resp.raise_for_status() - return resp.json() if resp.content else {"status": "success"} + resp = await self._http().request( + "DELETE", f"{self.host}/api/delete", json={"model": name}, timeout=30.0 + ) + resp.raise_for_status() + self.invalidate_tags_cache() + return resp.json() if resp.content else {"status": "success"} async def pull_model(self, name: str) -> AsyncIterator[dict]: """Télécharge un modèle en streamant la progression (/api/pull).""" - async with httpx.AsyncClient( - timeout=_STREAM_TIMEOUT, follow_redirects=True - ) as client: - async with client.stream( - "POST", f"{self.host}/api/pull", json={"name": name} - ) as resp: - await _raise_for_stream_status(resp) - async for line in resp.aiter_lines(): - if not line.strip(): - continue - try: - chunk = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(chunk, dict) and chunk.get("error"): - raise OllamaError(str(chunk["error"])) - yield chunk + async with self._http().stream( + "POST", + f"{self.host}/api/pull", + json={"name": name}, + timeout=_STREAM_TIMEOUT, + ) as resp: + await _raise_for_stream_status(resp) + async for line in resp.aiter_lines(): + if not line.strip(): + continue + try: + chunk = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(chunk, dict) and chunk.get("error"): + raise OllamaError(str(chunk["error"])) + yield chunk + self.invalidate_tags_cache() async def warm( self, model: str, keep_alive: str = "30m", options: dict | None = None @@ -135,21 +188,20 @@ class OllamaClient: # Le chargement se fait désormais en tâche de fond côté API Loki. On lui # laisse jusqu'à dix minutes pour les gros modèles ou un stockage lent. timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0) - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: - for attempt in range(2): - resp = await client.post( - f"{self.host}/api/generate", json=payload - ) - try: - # Inclut le corps JSON d'Ollama dans l'erreur (OOM, runner…), - # contrairement à raise_for_status qui ne montrait que « 500 ». - await _raise_for_stream_status(resp) - except OllamaError: - if resp.status_code >= 500 and attempt == 0: - await asyncio.sleep(2) - continue - raise - return resp.json() + for attempt in range(2): + resp = await self._http().post( + f"{self.host}/api/generate", json=payload, timeout=timeout + ) + try: + # Inclut le corps JSON d'Ollama dans l'erreur (OOM, runner…), + # contrairement à raise_for_status qui ne montrait que « 500 ». + await _raise_for_stream_status(resp) + except OllamaError: + if resp.status_code >= 500 and attempt == 0: + await asyncio.sleep(2) + continue + raise + return resp.json() raise OllamaError("préchargement interrompu sans réponse") async def chat( @@ -176,27 +228,24 @@ class OllamaClient: if keep_alive is not None: payload["keep_alive"] = keep_alive - async with httpx.AsyncClient( - timeout=_STREAM_TIMEOUT, follow_redirects=True - ) as client: - async with client.stream( - "POST", f"{self.host}/api/chat", json=payload - ) as resp: - await _raise_for_stream_status(resp) - async for line in resp.aiter_lines(): - if not line.strip(): - continue - try: - chunk = json.loads(line) - except json.JSONDecodeError: - # Ligne partielle / non-JSON : on l'ignore plutôt que de - # faire planter tout le flux. - continue - # Échec en cours de génération (OOM, contexte trop grand…) : - # Ollama l'émet dans le flux avec HTTP 200. On le remonte. - if isinstance(chunk, dict) and chunk.get("error"): - raise OllamaError(str(chunk["error"])) - yield chunk + async with self._http().stream( + "POST", f"{self.host}/api/chat", json=payload, timeout=_STREAM_TIMEOUT + ) as resp: + await _raise_for_stream_status(resp) + async for line in resp.aiter_lines(): + if not line.strip(): + continue + try: + chunk = json.loads(line) + except json.JSONDecodeError: + # Ligne partielle / non-JSON : on l'ignore plutôt que de + # faire planter tout le flux. + continue + # Échec en cours de génération (OOM, contexte trop grand…) : + # Ollama l'émet dans le flux avec HTTP 200. On le remonte. + if isinstance(chunk, dict) and chunk.get("error"): + raise OllamaError(str(chunk["error"])) + yield chunk ollama = OllamaClient() diff --git a/backend/app/rag.py b/backend/app/rag.py index d736beb..35ea48a 100644 --- a/backend/app/rag.py +++ b/backend/app/rag.py @@ -9,6 +9,7 @@ silencieusement (aucun impact sur le chat). """ from __future__ import annotations +import asyncio import json import logging import math @@ -60,7 +61,7 @@ async def resolve_embed_model(preference: str | None = None) -> str | None: value = None try: - for m in await ollama.list_models(): + for m in await ollama.list_models_cached(): name = (m.get("name") or "").lower() if any(h in name for h in _EMBED_HINTS): value = m["name"] @@ -79,6 +80,24 @@ def _cosine(a: list[float], b: list[float]) -> float: return dot / (na * nb) if na and nb else 0.0 +def _score_rows(qvec: list[float], rows: list) -> list[str]: + """Scoring cosinus sur toutes les mémoires — CPU pur, à lancer via to_thread. + + Jusqu'à _MAX_MEMORIES vecteurs : la boucle Python bloquerait l'event loop + (et donc tous les SSE en cours) pendant plusieurs dizaines de ms. + """ + scored: list[tuple[float, str]] = [] + for row in rows: + try: + score = _cosine(qvec, json.loads(row["embedding"])) + except (ValueError, TypeError): + continue + if score >= _MIN_SCORE: + scored.append((score, row["content"])) + scored.sort(reverse=True) + return [c for _, c in scored[:_TOP_K]] + + async def index_exchange( sid: str, user_text: str, assistant_text: str, *, embed_model: str | None ) -> None: @@ -127,16 +146,7 @@ async def recall( (sid,), ).fetchall() - scored = [] - for row in rows: - try: - score = _cosine(qvec, json.loads(row["embedding"])) - except (ValueError, TypeError): - continue - if score >= _MIN_SCORE: - scored.append((score, row["content"])) - scored.sort(reverse=True) - return [c for _, c in scored[:_TOP_K]] + return await asyncio.to_thread(_score_rows, qvec, rows) except (httpx.HTTPError, OSError) as exc: logger.warning("Rappel RAG impossible : %s", exc) return [] diff --git a/backend/app/router.py b/backend/app/router.py index 4eb60ce..2e2a912 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -1,17 +1,15 @@ """Routeur automatique : moteur code (Aider) ou boucle agent classique ? -Invisible pour l'utilisateur : chaque message est classé. - 1. Heuristique lexicale rapide (gratuite) — tranche les cas évidents. - 2. Cas ambigus : micro-classification par le modèle lui-même (3 jetons). +Invisible pour l'utilisateur : chaque message est classé par une heuristique +lexicale instantanée. Les cas ambigus partent vers la boucle agent, qui garde +l'outil `code_task` en secours — plus d'appel LLM bloquant avant le premier +token (l'ancien micro-classifieur coûtait un aller-retour modèle complet et +chargeait un runner divergent). """ from __future__ import annotations import re -import httpx - -from .ollama_client import ollama - # Vocabulaire fortement lié au code / au développement. _STRONG = re.compile( r"\b(code|coder?|script|fonction|classe|refactor|bug|d[ée]bug|html|css|" @@ -32,7 +30,7 @@ _ACTION = re.compile( def score_code_task(message: str) -> int: - """Score heuristique : >= 3 -> code, <= 1 -> agent, 2 -> ambigu.""" + """Score heuristique : >= 3 -> code, <= 2 -> agent.""" score = 0 if _STRONG.search(message): score += 2 @@ -45,37 +43,6 @@ def score_code_task(message: str) -> int: return score -async def is_code_task(message: str, model: str) -> bool: - """Décision finale : heuristique, puis LLM sur les cas ambigus.""" - score = score_code_task(message) - if score >= 3: - return True - if score <= 1: - return False - - # Ambigu : on demande au modèle (réponse d'un mot, quasi instantané). - try: - answer = "" - async for chunk in ollama.chat( - model, - [ - { - "role": "system", - "content": ( - "Tu classes des demandes. Réponds UNIQUEMENT par CODE " - "si la demande nécessite d'écrire ou modifier du code " - "ou des fichiers de projet, sinon CHAT." - ), - }, - {"role": "user", "content": message[:500]}, - ], - options={"num_predict": 4, "temperature": 0}, - stream=True, - ): - answer += chunk.get("message", {}).get("content", "") - if chunk.get("done"): - break - return "CODE" in answer.upper() - except (httpx.HTTPError, OSError): - # Ollama indisponible : la boucle agent gérera l'erreur proprement. - return False +def is_code_task(message: str) -> bool: + """Décision : heuristique pure, aucune requête modèle.""" + return score_code_task(message) >= 3 diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 0b2f3b0..8ecfe53 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -186,21 +186,43 @@ async def chat(req: ChatRequest) -> StreamingResponse: db.add_message(req.session_id, "user", req.content, None) # Routage automatique : moteur code si la demande est une tâche de code, - # que l'outil est actif et qu'Aider est disponible. + # que l'outil est actif et qu'Aider est disponible. Heuristique instantanée. use_code = ( cfg["tools"].get("code_task", True) and coder.available() - and await msg_router.is_code_task(req.content, model) + and msg_router.is_code_task(req.content) ) # Mémoire compressée : système + résumé des anciens tours + messages récents. convo = memory.build_convo(req.session_id, cfg["system_prompt"]) - # Mémoire long-terme (RAG) : souvenirs pertinents des autres sessions. - if cfg.get("rag_enabled", True): - memories = await rag.recall( - req.session_id, req.content, embed_model=cfg.get("embed_model") + # Options runner partagées par TOUS les appels au modèle de chat (plan, + # résumé, agent) : indispensables pour qu'Ollama garde le même runner. + run_opts = agent_config.runner_options(cfg) + keep = cfg.get("keep_alive", "30m") + + async def event_stream(): + yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"}) + + # Préparation du contexte APRÈS le start SSE et en PARALLÈLE : rappel + # RAG, plan et choix du modèle code partent ensemble au lieu de + # s'enchaîner en bloquant le premier token. + want_rag = cfg.get("rag_enabled", True) + want_plan = cfg.get("plan_mode", True) and ( + use_code or enhance.needs_plan(req.content) ) + if want_rag or want_plan or use_code: + yield _sse("status", {"message": "Préparation du contexte…"}) + + memories, plan, code_model = await asyncio.gather( + rag.recall(req.session_id, req.content, embed_model=cfg.get("embed_model")) + if want_rag else asyncio.sleep(0, result=[]), + enhance.make_plan(model, req.content, options=run_opts, keep_alive=keep) + if want_plan else asyncio.sleep(0, result=[]), + coder.pick_code_model(model, cfg.get("code_model")) + if use_code else asyncio.sleep(0, result=model), + ) + if memories: convo.insert(1, { "role": "system", @@ -208,20 +230,6 @@ async def chat(req: ChatRequest) -> StreamingResponse: + "\n---\n".join(memories), }) - # Moteur code : choisit le meilleur modèle code installé (config "auto"). - code_model = ( - await coder.pick_code_model(model, cfg.get("code_model")) - if use_code else model - ) - - # Plan-puis-exécute : les demandes complexes sont décomposées d'abord. - plan: list[str] = [] - if cfg.get("plan_mode", True) and (use_code or enhance.needs_plan(req.content)): - plan = await enhance.make_plan(model, req.content) - - async def event_stream(): - yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"}) - if plan: yield _sse("plan", {"steps": plan}) @@ -234,7 +242,9 @@ async def chat(req: ChatRequest) -> StreamingResponse: ) async for chunk in _code_stream(req, code_model, extra=instruction_plan, plan=plan): yield chunk - asyncio.create_task(memory.maybe_summarize(req.session_id, model)) + asyncio.create_task(memory.maybe_summarize( + req.session_id, model, options=run_opts, keep_alive=keep, + )) if cfg.get("rag_enabled", True): last = db.list_messages(req.session_id) answer = last[-1]["content"] if last else "" @@ -329,7 +339,10 @@ async def chat(req: ChatRequest) -> StreamingResponse: and not tools_meta ): yield _sse("status", {"message": "Relecture de la réponse…"}) - revised = await enhance.self_review(model, req.content, final_content) + revised = await enhance.self_review( + model, req.content, final_content, + options=run_opts, keep_alive=keep, + ) if revised: final_content = revised yield _sse("revision", {"content": revised}) @@ -362,7 +375,9 @@ async def chat(req: ChatRequest) -> StreamingResponse: }, ) # Tâches d'arrière-plan : compression de l'historique + mémoire RAG. - asyncio.create_task(memory.maybe_summarize(req.session_id, model)) + asyncio.create_task(memory.maybe_summarize( + req.session_id, model, options=run_opts, keep_alive=keep, + )) if cfg.get("rag_enabled", True) and final_content: asyncio.create_task(rag.index_exchange( req.session_id, req.content, final_content, diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index e4e1d2a..42dcaf5 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -1,7 +1,8 @@ -"""Routes de lecture du workspace (arborescence + contenu d'un fichier).""" +"""Routes du workspace : arborescence, contenu, téléchargement, suppression.""" from __future__ import annotations import os +import shutil from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse @@ -64,3 +65,27 @@ async def download_file(path: str) -> FileResponse: filename=os.path.basename(target), media_type="application/octet-stream", ) + + +@router.delete("") +async def delete_file(path: str) -> dict: + """Supprime un fichier ou un dossier (récursif) du workspace. + + Même confinement que le téléchargement (`_safe_path`) ; la racine du + workspace est refusée. Les dotfiles (.git…) ne sont jamais listés par + l'arborescence, donc inaccessibles depuis l'UI. + """ + try: + target = _safe_path(path) + except ToolError as exc: + raise HTTPException(400, str(exc)) from exc + root = os.path.abspath(settings.workspace_dir) + if os.path.abspath(target) == root: + raise HTTPException(400, "suppression de la racine du workspace refusée") + if os.path.isdir(target): + shutil.rmtree(target) + elif os.path.isfile(target): + os.remove(target) + else: + raise HTTPException(404, "fichier introuvable") + return {"deleted": path} diff --git a/backend/app/routes/models.py b/backend/app/routes/models.py index 6151440..c9f0ce4 100644 --- a/backend/app/routes/models.py +++ b/backend/app/routes/models.py @@ -46,7 +46,7 @@ async def status() -> dict: async def list_models() -> dict: """Modèles installés localement, formatés pour le sélecteur de l'UI.""" try: - raw = await ollama.list_models() + raw = await ollama.list_models_cached() except (httpx.HTTPError, OSError): return {"models": []} diff --git a/backend/app/routes/system.py b/backend/app/routes/system.py index 8a4d9fc..4b566b0 100644 --- a/backend/app/routes/system.py +++ b/backend/app/routes/system.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import shutil +import time import httpx import psutil @@ -15,11 +16,21 @@ router = APIRouter(prefix="/api/system", tags=["system"]) _NVIDIA_SMI = shutil.which("nvidia-smi") +# Cache court : le front interroge /stats en continu ; relancer un sous-processus +# nvidia-smi à chaque tick charge la machine qui héberge aussi Ollama. +_GPU_CACHE_TTL = 5.0 +_gpu_cache: dict = {"at": 0.0, "value": None} + async def _gpu_stats() -> dict | None: - """Utilisation GPU/VRAM via nvidia-smi ; None si absent (pas de GPU NVIDIA).""" + """Utilisation GPU/VRAM via nvidia-smi ; None si absent (pas de GPU NVIDIA). + + Résultat mis en cache ~5 s pour limiter les sous-processus. + """ if not _NVIDIA_SMI: return None + if time.monotonic() - _gpu_cache["at"] < _GPU_CACHE_TTL: + return _gpu_cache["value"] try: proc = await asyncio.create_subprocess_exec( _NVIDIA_SMI, @@ -31,14 +42,16 @@ async def _gpu_stats() -> dict | None: out, _ = await asyncio.wait_for(proc.communicate(), timeout=3) line = out.decode().strip().splitlines()[0] util, used, total, name = (p.strip() for p in line.split(",")) - return { + value = { "name": name, "util_pct": float(util), "vram_used_mb": float(used), "vram_total_mb": float(total), } except Exception: - return None + value = None + _gpu_cache.update(at=time.monotonic(), value=value) + return value @router.get("/stats") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4833477..b51d79b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,27 +14,29 @@ export default function App() { refreshStatus, refreshSystemStats, refreshModels, - refreshConfig, refreshLoadedModels, + refreshSessions, } = useStore(); - // Au démarrage : statut Ollama + modèles + config. Poll du statut, des stats - // système et des modèles chargés en VRAM (indicateur de préchargement). + // Au démarrage : statut Ollama + modèles + sessions. La config est chargée + // par setSelectedModel (via refreshModels) — pas de double fetch. Poll du + // statut, des stats système (5 s, apparié au cache GPU serveur) et des + // modèles chargés en VRAM (indicateur de préchargement). useEffect(() => { refreshStatus(); refreshSystemStats(); refreshModels(); - refreshConfig(); refreshLoadedModels(); + refreshSessions(); const statusId = setInterval(refreshStatus, 10000); - const statsId = setInterval(refreshSystemStats, 2000); + const statsId = setInterval(refreshSystemStats, 5000); const loadedId = setInterval(refreshLoadedModels, 8000); return () => { clearInterval(statusId); clearInterval(statsId); clearInterval(loadedId); }; - }, [refreshStatus, refreshSystemStats, refreshModels, refreshConfig, refreshLoadedModels]); + }, [refreshStatus, refreshSystemStats, refreshModels, refreshLoadedModels, refreshSessions]); return (