From cb2872c78affcf247e913eb200e0ef7625d82160 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 18 Jul 2026 13:58:18 +0200 Subject: [PATCH] perf: fix model reload thrash and cut time-to-first-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama reloaded the chat model mid-message because plan/summary/router calls sent divergent runner options (no num_ctx/num_batch) and omitted keep_alive — the main cause of perceived slowness. - share one keep-alive httpx.AsyncClient for all Ollama calls - unify runner options (runner_options) + keep_alive on every model call, embeddings included - drop the blocking LLM routing fallback (pure lexical heuristic) - run RAG recall + plan + code-model pick in parallel inside the SSE stream, after the start event - RAG cosine scoring off the event loop; cache /api/tags 30s and nvidia-smi 5s; frontend polls 2s->5s, warm poll backoff, dedup config fetch feat: working session menu in TopBar (switch/create/rename/delete) feat: workspace file deletion (DELETE /api/files + UI trash buttons) docs: recommended Ollama env vars (KEEP_ALIVE, MAX_LOADED_MODELS...) Co-Authored-By: Claude Opus 4.8 --- README.md | 38 ++++- backend/app/agent_config.py | 29 +++- backend/app/coder.py | 2 +- backend/app/enhance.py | 43 ++++- backend/app/main.py | 3 + backend/app/memory.py | 17 +- backend/app/ollama_client.py | 217 +++++++++++++++--------- backend/app/rag.py | 32 ++-- backend/app/router.py | 51 +----- backend/app/routes/chat.py | 61 ++++--- backend/app/routes/files.py | 27 ++- backend/app/routes/models.py | 2 +- backend/app/routes/system.py | 19 ++- frontend/src/App.tsx | 14 +- frontend/src/api/client.ts | 29 +++- frontend/src/components/Icon.tsx | 6 + frontend/src/components/SessionMenu.tsx | 174 +++++++++++++++++++ frontend/src/components/TopBar.tsx | 8 +- frontend/src/lib/time.ts | 8 + frontend/src/panels/ActivityViews.tsx | 18 +- frontend/src/panels/LeftPanel.tsx | 29 +++- frontend/src/panels/PreviewPanel.tsx | 35 ++-- frontend/src/store/useStore.ts | 34 +++- 23 files changed, 672 insertions(+), 224 deletions(-) create mode 100644 frontend/src/components/SessionMenu.tsx create mode 100644 frontend/src/lib/time.ts 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 (
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0062ecc..fe6a372 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -158,7 +158,11 @@ export async function warmModel( throw await apiError(res, `préchargement refusé (${res.status})`); } - for (let attempt = 0; attempt < 300; attempt++) { + // Backoff progressif : réactif au début (modèle déjà chargé), espacé ensuite + // pour ne pas marteler l'API pendant un long chargement. + const deadline = Date.now() + 10 * 60 * 1000; + let delay = 1000; + while (Date.now() < deadline) { const statusRes = await fetch( `/api/models/warm/status?name=${encodeURIComponent(name)}`, { cache: "no-store" } @@ -171,7 +175,8 @@ export async function warmModel( if (status.state === "error") { throw new Error(status.error ?? "préchargement impossible"); } - await wait(2000); + await wait(delay); + delay = Math.min(delay * 1.5, 5000); } throw new Error("préchargement toujours en cours après 10 minutes"); } @@ -405,6 +410,15 @@ export async function fileContent(path: string): Promise { return (await res.json()).content; } +export async function deleteFile(path: string): Promise { + const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`, { + method: "DELETE", + }); + if (!res.ok) { + throw await apiError(res, `suppression impossible (${res.status})`); + } +} + export function downloadFile(path: string): void { const link = document.createElement("a"); link.href = `/api/files/download?path=${encodeURIComponent(path)}`; @@ -439,6 +453,17 @@ export async function deleteSession(id: string): Promise { await fetch(`/api/sessions/${id}`, { method: "DELETE" }); } +export async function renameSession(id: string, title: string): Promise { + const res = await fetch(`/api/sessions/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); + if (!res.ok) { + throw await apiError(res, `renommage impossible (${res.status})`); + } +} + /** Envoie un message et streame la réponse de l'agent via SSE. */ export async function streamChat( body: { session_id: string; content: string; model?: string; mode?: string }, diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 976d966..1477cb2 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -85,6 +85,12 @@ export const DownloadIcon = ({ size = 14, className }: Props) => ( ); +export const TrashIcon = ({ size = 14, className }: Props) => ( + + + +); + export const CopyIcon = ({ size = 14, className }: Props) => ( diff --git a/frontend/src/components/SessionMenu.tsx b/frontend/src/components/SessionMenu.tsx new file mode 100644 index 0000000..562419d --- /dev/null +++ b/frontend/src/components/SessionMenu.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from "react"; +import { useStore } from "../store/useStore"; +import { ChevronDown, PlusIcon, TrashIcon } from "./Icon"; +import { relTime } from "../lib/time"; + +/** Sélecteur de session de la barre supérieure : titre courant + menu déroulant + * (nouvelle session, liste, renommage inline, suppression). */ +export function SessionMenu() { + const { + sessions, + currentSessionId, + streamingSessionId, + newSession, + openSession, + removeSession, + renameSession, + } = useStore(); + const [open, setOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(""); + const rootRef = useRef(null); + + const current = sessions.find((s) => s.id === currentSessionId); + + // Fermeture au clic extérieur + Escape. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + setEditingId(null); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + setEditingId(null); + } + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const commitRename = async (id: string) => { + const title = draft.trim(); + setEditingId(null); + if (title) await renameSession(id, title); + }; + + return ( +
+ + + {open && ( +
+ + +
+ {sessions.length === 0 && ( +
+ Aucune session enregistrée. +
+ )} + {sessions.map((s) => { + const active = s.id === currentSessionId; + const working = s.id === streamingSessionId; + return ( +
{ + if (editingId === s.id) return; + setOpen(false); + void openSession(s.id); + }} + className={`group cursor-pointer border-b-2 border-chrome-3 px-3 py-2 last:border-b-0 ${ + active ? "bg-chrome-3" : "hover:bg-chrome-3" + }`} + > +
+ {editingId === s.id ? ( + setDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter") void commitRename(s.id); + if (e.key === "Escape") { + e.stopPropagation(); + setEditingId(null); + } + }} + onBlur={() => setEditingId(null)} + className="min-w-0 flex-1 border-2 border-line bg-card-deep px-1.5 py-0.5 text-[13px] text-white outline-none" + /> + ) : ( + + {s.title} + + )} + {working && ( + + EN COURS + + )} + + +
+
+ {relTime(s.updated_at)} · {s.message_count ?? 0} message + {(s.message_count ?? 0) > 1 ? "s" : ""} +
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 3b28dce..592283e 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import { useStore } from "../store/useStore"; import { getVersion } from "../api/client"; -import { ChevronDown, LokiMark } from "./Icon"; +import { LokiMark } from "./Icon"; import { ModelSelector } from "./ModelSelector"; +import { SessionMenu } from "./SessionMenu"; /** Barre supérieure sombre : logo, fil d'Ariane, statut Ollama, sélecteur. */ export function TopBar() { @@ -33,10 +34,7 @@ export function TopBar() {
-
- Nouvelle session - -
+
diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts new file mode 100644 index 0000000..af6d29a --- /dev/null +++ b/frontend/src/lib/time.ts @@ -0,0 +1,8 @@ +/** Date relative courte en français (« il y a 5 min »). */ +export function relTime(ts: number): string { + const diff = Date.now() / 1000 - ts; + if (diff < 60) return "à l'instant"; + if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`; + if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`; + return `il y a ${Math.floor(diff / 86400)} j`; +} diff --git a/frontend/src/panels/ActivityViews.tsx b/frontend/src/panels/ActivityViews.tsx index 40b7a61..665a19c 100644 --- a/frontend/src/panels/ActivityViews.tsx +++ b/frontend/src/panels/ActivityViews.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { downloadFile, type FileNode } from "../api/client"; -import { DownloadIcon } from "../components/Icon"; +import { DownloadIcon, TrashIcon } from "../components/Icon"; import { useStore } from "../store/useStore"; export function HistoryView({ onOpen }: { onOpen: () => void }) { @@ -61,7 +61,14 @@ export function FilesView() { } function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) { - const { openPreview, previewPath } = useStore(); + const { openPreview, previewPath, removeFile } = useStore(); + const confirmDelete = (node: FileNode) => { + const msg = + node.type === "dir" + ? `Supprimer le dossier ${node.path} et tout son contenu ?` + : `Supprimer ${node.path} ?`; + if (window.confirm(msg)) void removeFile(node.path); + }; return ( <> {nodes.map((node) => ( @@ -93,6 +100,13 @@ function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) { Télécharger )} +
{node.children && }
diff --git a/frontend/src/panels/LeftPanel.tsx b/frontend/src/panels/LeftPanel.tsx index ca3ef17..ab2bca5 100644 --- a/frontend/src/panels/LeftPanel.tsx +++ b/frontend/src/panels/LeftPanel.tsx @@ -1,7 +1,8 @@ import { useEffect } from "react"; import { useStore } from "../store/useStore"; -import { DownloadIcon, PlusIcon } from "../components/Icon"; +import { DownloadIcon, PlusIcon, TrashIcon } from "../components/Icon"; import { downloadFile, type FileNode } from "../api/client"; +import { relTime } from "../lib/time"; /** Panneau gauche : historique des sessions + arborescence de fichiers. */ export function LeftPanel() { @@ -116,7 +117,14 @@ export function LeftPanel() { } function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) { - const { openPreview, previewPath } = useStore(); + const { openPreview, previewPath, removeFile } = useStore(); + const confirmDelete = (n: FileNode) => { + const msg = + n.type === "dir" + ? `Supprimer le dossier ${n.path} et tout son contenu ?` + : `Supprimer ${n.path} ?`; + if (window.confirm(msg)) void removeFile(n.path); + }; return ( <> {nodes.map((n) => { @@ -156,6 +164,16 @@ function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) { )} +
{n.children && } @@ -165,10 +183,3 @@ function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) { ); } -function relTime(ts: number): string { - const diff = Date.now() / 1000 - ts; - if (diff < 60) return "à l'instant"; - if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`; - if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`; - return `il y a ${Math.floor(diff / 86400)} j`; -} diff --git a/frontend/src/panels/PreviewPanel.tsx b/frontend/src/panels/PreviewPanel.tsx index 589fa9f..56c504e 100644 --- a/frontend/src/panels/PreviewPanel.tsx +++ b/frontend/src/panels/PreviewPanel.tsx @@ -8,13 +8,14 @@ import { type GitCommit, type ToolCall, } from "../api/client"; -import { DownloadIcon } from "../components/Icon"; +import { DownloadIcon, TrashIcon } from "../components/Icon"; type TabId = "preview" | "code" | "logs" | "git"; /** Panneau droit : onglets Aperçu / Code / Logs. */ export function PreviewPanel() { - const { previewPath, previewContent, messages, streamTools } = useStore(); + const { previewPath, previewContent, messages, streamTools, removeFile } = + useStore(); const [tab, setTab] = useState("preview"); const [width, setWidth] = useState(() => { const saved = Number(window.localStorage.getItem("loki.preview.width")); @@ -95,14 +96,28 @@ export function PreviewPanel() { {previewPath && ( - + <> + + + )} diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index 16c084b..d6618f9 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { createSession, + deleteFile, deleteSession, getConfig, getModels, @@ -12,6 +13,7 @@ import { fileContent, listFiles, listSessions, + renameSession as apiRenameSession, runShell, saveConfig, streamChat, @@ -73,6 +75,8 @@ interface LokiState { newSession: () => Promise; openSession: (id: string) => Promise; removeSession: (id: string) => Promise; + renameSession: (id: string, title: string) => Promise; + removeFile: (path: string) => Promise; sendMessage: (content: string) => Promise; stopStreaming: () => void; @@ -278,6 +282,23 @@ export const useStore = create((set, get) => ({ await get().refreshSessions(); }, + renameSession: async (id, title) => { + const clean = title.trim(); + if (!clean) return; + await apiRenameSession(id, clean); + await get().refreshSessions(); + }, + + removeFile: async (path) => { + await deleteFile(path); + // Ferme l'aperçu si le fichier supprimé (ou son dossier) y est affiché. + const preview = get().previewPath; + if (preview && (preview === path || preview.startsWith(path + "/"))) { + set({ previewPath: null, previewContent: "" }); + } + await get().refreshFiles(); + }, + stopStreaming: () => { activeStreamController?.abort(); activeStreamController = null; @@ -403,10 +424,15 @@ export const useStore = create((set, get) => ({ streamNotice: null, streamTools: [], }); - // Recharge depuis la base + l'arborescence (fichiers créés par l'agent). - if (get().currentSessionId === sid) await get().openSession(sid!); - await get().refreshSessions(); - await get().refreshFiles(); + // Recharge depuis la base + l'arborescence (fichiers créés par + // l'agent) — trois requêtes indépendantes, en parallèle. + await Promise.all([ + get().currentSessionId === sid + ? get().openSession(sid!) + : Promise.resolve(), + get().refreshSessions(), + get().refreshFiles(), + ]); if (writtenHtml) await get().openPreview(writtenHtml.args.path as string); }, onError: (msg) => {