mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
552548e4b4
commit
cb2872c78a
23 files changed
+672
-224
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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": []}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user