perf: fix model reload thrash and cut time-to-first-token

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-18 13:58:18 +02:00
1 parent 552548e4b4
commit cb2872c78a
23 files changed
+672 -224

No files matched your search

+35 -3
View File
@@ -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.
+20 -9
View File
@@ -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]
+1 -1
View File
@@ -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
+38 -5
View File
@@ -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
+3
View File
@@ -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(
+14 -3
View File
@@ -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", "")
+133 -84
View File
@@ -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()
+21 -11
View File
@@ -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 []
+9 -42
View File
@@ -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
+38 -23
View File
@@ -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,
+26 -1
View File
@@ -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}
+1 -1
View File
@@ -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": []}
+16 -3
View File
@@ -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")
+8 -6
View File
@@ -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 (
<div className="flex h-full flex-col bg-base text-ink">
+27 -2
View File
@@ -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<string> {
return (await res.json()).content;
}
export async function deleteFile(path: string): Promise<void> {
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<void> {
await fetch(`/api/sessions/${id}`, { method: "DELETE" });
}
export async function renameSession(id: string, title: string): Promise<void> {
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 },
+6
View File
@@ -85,6 +85,12 @@ export const DownloadIcon = ({ size = 14, className }: Props) => (
</svg>
);
export const TrashIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.9}>
<path d="M4 7h16M10 7V5h4v2M9 7l.8 13h4.4L15 7M6.5 7l.7 13h9.6l.7-13" />
</svg>
);
export const CopyIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.7}>
<rect x="9" y="9" width="11" height="11" rx="2" />
+174
View File
@@ -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<string | null>(null);
const [draft, setDraft] = useState("");
const rootRef = useRef<HTMLDivElement>(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 (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex cursor-pointer items-center gap-2 text-[13px] text-on-dark hover:text-white"
title="Sessions"
>
<span className="max-w-[220px] truncate">
{current?.title ?? "Nouvelle session"}
</span>
<ChevronDown className="text-on-dark-3" />
</button>
{open && (
<div
className="absolute left-0 top-[calc(100%+9px)] z-30 w-[300px] border-[3px] border-line bg-chrome-2 shadow-hard"
style={{ borderRadius: 7 }}
>
<button
onClick={async () => {
setOpen(false);
await newSession();
}}
className="flex w-full cursor-pointer items-center gap-2 border-b-[3px] border-chrome-3 px-3 py-2.5 text-[13px] font-bold text-accent hover:bg-chrome-3"
>
<PlusIcon />
Nouvelle session
</button>
<div className="scr max-h-[320px] overflow-auto">
{sessions.length === 0 && (
<div className="px-3 py-4 text-center text-[12px] text-on-dark-3">
Aucune session enregistrée.
</div>
)}
{sessions.map((s) => {
const active = s.id === currentSessionId;
const working = s.id === streamingSessionId;
return (
<div
key={s.id}
onClick={() => {
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"
}`}
>
<div className="flex items-center gap-1.5">
{editingId === s.id ? (
<input
autoFocus
value={draft}
onChange={(e) => 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"
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-[13px] ${
active ? "text-white" : "text-on-dark"
}`}
>
{s.title}
</span>
)}
{working && (
<span
className="flex-none border-2 border-line bg-accent px-1 py-0.5 text-[9px] leading-none text-white"
title="Session en cours de travail"
>
EN COURS
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
setEditingId(s.id);
setDraft(s.title);
}}
className="hidden flex-none text-[11px] text-on-dark-3 hover:text-white group-hover:block"
title="Renommer la session"
>
✎
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (window.confirm("Supprimer cette session ?")) {
void removeSession(s.id);
}
}}
className="hidden flex-none text-on-dark-3 hover:text-accent group-hover:block"
title="Supprimer la session"
>
<TrashIcon size={12} />
</button>
</div>
<div className="mt-0.5 text-[11px] text-on-dark-3">
{relTime(s.updated_at)} · {s.message_count ?? 0} message
{(s.message_count ?? 0) > 1 ? "s" : ""}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
+3 -5
View File
@@ -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() {
<div className="h-6 w-[3px] bg-chrome-3" />
<div className="flex items-center gap-2 text-[13px] text-on-dark">
<span>Nouvelle session</span>
<ChevronDown className="text-on-dark-3" />
</div>
<SessionMenu />
<div className="flex-1" />
+8
View File
@@ -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`;
}
+16 -2
View File
@@ -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 }) {
<DownloadIcon size={12} /> Télécharger
</button>
)}
<button
onClick={() => confirmDelete(node)}
className="flex items-center gap-1 border-2 border-line bg-base px-2 py-1 text-[11px] text-warn"
title={`Supprimer ${node.name}`}
>
<TrashIcon size={12} /> Supprimer
</button>
</div>
{node.children && <WorkspaceTree nodes={node.children} depth={depth + 1} />}
</div>
+20 -9
View File
@@ -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 }) {
<DownloadIcon size={13} />
</button>
)}
<button
onClick={(event) => {
event.stopPropagation();
confirmDelete(n);
}}
className="hidden flex-none text-muted-2 hover:text-warn group-hover:block"
title={`Supprimer ${n.name}`}
>
<TrashIcon size={13} />
</button>
</div>
{n.children && <FileTree nodes={n.children} depth={depth + 1} />}
</div>
@@ -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`;
}
+25 -10
View File
@@ -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<TabId>("preview");
const [width, setWidth] = useState(() => {
const saved = Number(window.localStorage.getItem("loki.preview.width"));
@@ -95,14 +96,28 @@ export function PreviewPanel() {
</span>
</div>
{previewPath && (
<button
onClick={() => downloadFile(previewPath)}
className="flex h-8 items-center gap-1.5 border-[3px] border-line bg-card px-2.5 text-[12px] text-accent"
title={`Télécharger ${previewPath}`}
>
<DownloadIcon size={13} />
Télécharger
</button>
<>
<button
onClick={() => downloadFile(previewPath)}
className="flex h-8 items-center gap-1.5 border-[3px] border-line bg-card px-2.5 text-[12px] text-accent"
title={`Télécharger ${previewPath}`}
>
<DownloadIcon size={13} />
Télécharger
</button>
<button
onClick={() => {
if (window.confirm(`Supprimer ${previewPath} ?`)) {
void removeFile(previewPath);
}
}}
className="flex h-8 items-center gap-1.5 border-[3px] border-line bg-card px-2.5 text-[12px] text-warn"
title={`Supprimer ${previewPath}`}
>
<TrashIcon size={13} />
Supprimer
</button>
</>
)}
</div>
+30 -4
View File
@@ -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<void>;
openSession: (id: string) => Promise<void>;
removeSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
removeFile: (path: string) => Promise<void>;
sendMessage: (content: string) => Promise<void>;
stopStreaming: () => void;
@@ -278,6 +282,23 @@ export const useStore = create<LokiState>((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<LokiState>((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) => {