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

+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")