Retire la pile Python/React : Loki repart de la base Go d'AJEAN

L'ancien Loki (FastAPI + React + Ollama) est remplacé par le fork d'AJEAN.
Tout reste accessible dans l'historique (git show bb4fb1b:backend/app/agent.py).
This commit is contained in:
Loki committed 2026-08-14 21:30:13 +00:00
1 parent 9f1b0562cf
commit 2164b46319
87 files changed
-18730

No files matched your search

View File
Whitespace-only changes.
-42
View File
@@ -1,42 +0,0 @@
"""Routes du benchmark de modèles."""
from __future__ import annotations
import json
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import bench
router = APIRouter(prefix="/api/bench", tags=["bench"])
class BenchRequest(BaseModel):
model: str
@router.get("")
async def scores() -> dict:
return {"scores": bench.get_scores()}
@router.post("")
async def run(req: BenchRequest) -> StreamingResponse:
async def event_stream():
try:
async for ev in bench.run_bench(req.model):
etype = ev.pop("type")
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
except Exception as exc:
payload = json.dumps(
{"message": f"benchmark interrompu : {str(exc)[:200]}"},
ensure_ascii=False,
)
yield f"event: error\ndata: {payload}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
)
-746
View File
@@ -1,746 +0,0 @@
"""Route de conversation : routage automatique agent / moteur code + SSE.
Flux :
1. on enregistre le message utilisateur ;
2. le routeur classe la demande : tâche de code -> moteur code (Aider),
sinon -> boucle agentique classique (qui peut elle-même appeler code_task) ;
3. on relaie tokens et événements d'outils au client (SSE) ;
4. on enregistre la réponse finale de l'assistant + le récap des outils.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from contextlib import suppress
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import (
agent_config, coder, db, enhance, memory, memory_notes, rag, skills, tools,
)
from .. import router as msg_router
from ..tools import check_html, _safe_path
from ..agent import run_agent
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api", tags=["chat"])
logger = logging.getLogger(__name__)
class ChatRequest(BaseModel):
session_id: str
content: str
model: str | None = None
# Mode d'exécution : "plan" (lecture seule), "build" (normal), "yolo" (auto).
mode: str = "build"
# Outils autorisés en mode Plan : lecture/analyse uniquement.
_READONLY_TOOLS = {"read_file", "list_dir", "grep_search", "run_check"}
def _apply_mode(cfg: dict, mode: str) -> dict:
"""Adapte la config au mode d'exécution choisi dans le composer."""
cfg = dict(cfg)
if mode == "plan":
# Lecture seule : aucune écriture, aucun shell, aucun moteur code.
cfg["tools"] = {
name: (on and name in _READONLY_TOOLS)
for name, on in cfg["tools"].items()
}
cfg["plan_mode"] = True
elif mode == "yolo":
# Autonomie maximale : plus de validation shell.
cfg["confirm_shell"] = False
# "build" : comportement par défaut (inchangé).
return cfg
async def _placement_notice(model: str) -> str | None:
"""Avertit si le modèle tourne (au moins en partie) hors du GPU.
C'est LA cause d'un débit ridicule (quelques jetons/s) : Ollama a placé
tout ou partie des couches en RAM. Loki ne peut pas le corriger — le
placement appartient à Ollama — mais il peut le NOMMER, au lieu de
laisser croire à un blocage de l'application.
"""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return None
for m in loaded:
if (m.get("name") or m.get("model")) != model:
continue
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0 or vram >= size * 0.99:
return None
pct = int(vram / size * 100)
in_ram = (size - vram) / 1e9
where = "entièrement en RAM" if pct == 0 else f"{pct} % en VRAM"
return (
f"⚠️ {model} tourne {where} ({in_ram:.1f} Go hors GPU) : "
"le débit sera de quelques jetons par seconde. Réduis le contexte, "
"prends un modèle plus petit, ou répartis sur tes GPU côté Ollama."
)
return None
def _loading_message(model: str, seconds: float) -> str:
"""Message d'attente avant le premier jeton (chargement du modèle)."""
waited = int(seconds)
delay = f"{waited // 60} min" if waited >= 60 else f"{waited} s"
text = f"Chargement de {model} en mémoire… ({delay})"
if waited >= 90:
# On CONSTATE l'attente sans présumer de la cause : selon la machine,
# un gros modèle peut se répartir sur plusieurs GPU, ou déborder sur
# le CPU. Le panneau Matériel donne le placement réel.
text += " — placement réel visible dans Réglages › Matériel."
return text
def _merge_system(convo: list[dict], extras: list[str]) -> None:
"""Garantit UN SEUL message système, en première position.
Beaucoup de templates Ollama (Gemma, Mistral…) contiennent un garde
« {% if role == 'system' and not loop.first %}{{ raise_exception(...) }} » :
tout message système qui n'est pas le premier fait échouer la requête
entière en 400 (« System message must be at the beginning »), y compris
la génération du parseur d'outils.
On fusionne donc, dans l'ordre : l'invite système, les éventuels systèmes
égarés (résumé de session…) puis les consignes du tour. Modifie ``convo``
sur place.
"""
head = ""
stray: list[str] = []
rest: list[dict] = []
for i, msg in enumerate(convo):
if msg.get("role") == "system":
text = (msg.get("content") or "").strip()
if i == 0:
head = text
elif text:
stray.append(text)
else:
rest.append(msg)
blocks = [b for b in (head, *stray, *(e.strip() for e in extras if e)) if b]
convo[:] = ([{"role": "system", "content": "\n\n".join(blocks)}] if blocks else []) + rest
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _prev_was_code(history: list[dict]) -> bool:
"""Le dernier tour assistant de la session était-il un travail de code ?"""
for m in reversed(history):
if m["role"] != "assistant":
continue
meta = m.get("meta") or {}
if meta.get("engine") == "code":
return True
tools = meta.get("tools") or []
return any(
t.get("name") in ("code_task", "write_file", "edit_file")
for t in tools
)
return False
def _session_code_context(history: list[dict]) -> tuple[str, list[str]]:
"""Récap compact du travail en cours + fichiers touchés dans la session.
Le moteur code ne reçoit que le message courant : sur une reprise
(« corrige les bugs »), sans ce récap il ignore quel fichier, quel projet
et quelle demande d'origine. Les fichiers touchés servent aussi de cible
par défaut pour Aider.
"""
root = tools.active_root()
files: list[str] = []
for m in history:
if m["role"] != "assistant":
continue
for t in (m.get("meta") or {}).get("tools") or []:
candidates: list[str] = []
path = (t.get("args") or {}).get("path")
if t.get("name") in ("write_file", "edit_file") and path:
candidates.append(str(path))
for f in t.get("files") or []:
candidates.append(str(f))
for c in candidates:
rel = c.replace("\\", "/").lstrip("./")
if rel not in files and os.path.isfile(os.path.join(root, rel)):
files.append(rel)
user_msgs = [m["content"].strip() for m in history if m["role"] == "user"]
lines: list[str] = []
if user_msgs:
lines.append(f"- Demande initiale : {user_msgs[0][:200]}")
for prev in user_msgs[-2:]:
if prev != user_msgs[0]:
lines.append(f"- Puis : {prev[:200]}")
if files:
lines.append(f"- Fichiers déjà créés/modifiés : {', '.join(files[:8])}")
recap = (
"Contexte de la session (travail en cours) :\n" + "\n".join(lines)
if lines else ""
)
return recap, files
def _workspace_listing(limit: int = 40) -> list[str]:
"""Chemins relatifs des fichiers de la racine active (aperçu compact)."""
root = tools.active_root()
out: list[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for name in sorted(filenames):
if name.startswith("."):
continue
rel = os.path.relpath(os.path.join(dirpath, name), root)
out.append(rel.replace("\\", "/"))
if len(out) >= limit:
return out
return out
_FILE_MENTION = re.compile(r"[\w][\w./\\-]*\.[a-z0-9]{1,5}\b", re.I)
def _mentioned_files(text: str) -> list[str]:
"""Fichiers du workspace explicitement cités dans le message.
Transmis au moteur code pour qu'Aider travaille directement sur les bons
fichiers au lieu de deviner via la repo map.
"""
root = tools.active_root()
out: list[str] = []
for raw in _FILE_MENTION.findall(text):
rel = raw.replace("\\", "/").lstrip("./")
if os.path.isfile(os.path.join(root, rel)) and rel not in out:
out.append(rel)
return out[:8]
async def _run_aider_keepalive(
instruction: str, model: str, files: list[str] | None = None
):
"""Lance Aider dans un thread en gardant le flux SSE vivant."""
# Racine résolue AVANT le thread : la contextvar projet ne suit pas
# dans asyncio.to_thread.
root = tools.active_root()
task = asyncio.create_task(
asyncio.to_thread(coder.run_code_task, instruction, model, files, root)
)
while not task.done():
await asyncio.sleep(10)
if not task.done():
yield None # signal keepalive
yield await task
async def _code_stream(
req: ChatRequest,
model: str,
*,
extra: str = "",
plan: list[str] | None = None,
files: list[str] | None = None,
):
"""Chemin « moteur code » : Aider + vérification HTML avec auto-correction."""
instruction = req.content + (extra or "")
yield _sse("tool_call", {"name": "code_task", "args": {"instruction": req.content}})
result = None
async for item in _run_aider_keepalive(instruction, model, files):
if item is None:
yield ": keepalive\n\n"
else:
result = item
status = "ok" if result.get("ok") else "error"
record = {
"name": "code_task",
"args": {"instruction": req.content},
"summary": result.get("summary", "terminé"),
"status": status,
}
tools_meta = [record]
yield _sse("tool_result", record)
all_files = list(result.get("files") or [])
# Vérification des pages HTML produites + une passe d'auto-correction.
html_issues: list[str] = []
for f in all_files:
if f.lower().endswith((".html", ".htm")):
try:
issues = check_html(_safe_path(f))
except Exception:
issues = []
if issues:
html_issues.append(f"{f} : " + " ; ".join(issues))
if html_issues and result.get("ok"):
yield _sse("tool_call", {"name": "html_check", "args": {"path": "vérification"}})
yield _sse("tool_result", {
"name": "html_check", "args": {"path": "vérification"},
"summary": " | ".join(html_issues)[:200], "status": "error",
})
tools_meta.append({
"name": "html_check", "args": {},
"summary": " | ".join(html_issues)[:200], "status": "error",
})
fix_instruction = (
"Corrige ces problèmes détectés dans les fichiers HTML, sans rien "
"casser d'autre :\n" + "\n".join(html_issues)
)
fix = None
async for item in _run_aider_keepalive(fix_instruction, model):
if item is None:
yield ": keepalive\n\n"
else:
fix = item
fix_rec = {
"name": "code_task",
"args": {"instruction": "auto-correction HTML"},
"summary": fix.get("summary", "terminé"),
"status": "ok" if fix.get("ok") else "error",
}
tools_meta.append(fix_rec)
yield _sse("tool_call", {"name": "code_task", "args": fix_rec["args"]})
yield _sse("tool_result", fix_rec)
for f in fix.get("files") or []:
if f not in all_files:
all_files.append(f)
if fix.get("text"):
result["text"] = (result.get("text") or "") + "\n\n" + fix["text"]
# Cartes par fichier modifié (réutilise le rendu write_file de l'UI).
for f in all_files:
file_rec = {
"name": "write_file",
"args": {"path": f},
"summary": "modifié par le moteur code",
"status": "ok",
}
tools_meta.append(file_rec)
yield _sse("tool_call", {"name": "write_file", "args": {"path": f}})
yield _sse("tool_result", file_rec)
text = result.get("text") or (
"" if result.get("ok") else f"⚠️ {result.get('summary', 'échec du moteur code')}"
)
if text:
yield _sse("token", {"content": text})
meta: dict = {"tools": tools_meta, "engine": "code"}
if plan:
meta["plan"] = plan
db.add_message(req.session_id, "assistant", text, model, meta=meta)
yield _sse("done", {"content": text, "tools": tools_meta})
@router.post("/chat")
async def chat(req: ChatRequest) -> StreamingResponse:
session = db.get_session(req.session_id)
if not session:
raise HTTPException(404, "session introuvable")
model = req.model or session.get("model") or settings.default_model
cfg = _apply_mode(agent_config.get_config(model), req.mode)
# Projet de la session : re-racine outils, shell, Aider et aides de
# contexte pour tout le tour. Projet disparu -> retour racine + notice.
project = session.get("project") or None
project_missing = False
if project:
proj_dir = os.path.join(os.path.abspath(settings.workspace_dir), project)
if not os.path.isdir(proj_dir):
project_missing, project = True, None
tools.set_project(project)
history = db.list_messages(req.session_id)
# Premier message : titre la session avec un extrait.
if not history:
title = req.content.strip().split("\n")[0][:60] or "Nouvelle session"
db.rename_session(req.session_id, title)
db.add_message(req.session_id, "user", req.content, None)
# Routage automatique : moteur code si la demande est une tâche de code —
# ou la SUITE d'un travail de code (« ajoute un bouton », « continue »…),
# que l'heuristique seule classerait à tort en discussion.
prev_code = _prev_was_code(history)
use_code = (
cfg["tools"].get("code_task", True)
and coder.available()
and (
msg_router.is_code_task(req.content)
or (prev_code and msg_router.is_code_followup(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"])
# 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"})
if project_missing:
yield _sse("notice", {"message": (
"Projet de la session introuvable sur le disque — retour au "
"workspace."
)})
# 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", False)
# Un plan « from scratch » n'a de sens que pour une NOUVELLE construction.
# Pour une correction ou la suite d'un travail (l'appli existe déjà,
# session code en cours, formulation de suivi), il est hors-sujet et
# perturbe : on ne replanifie pas l'architecture à chaque message.
is_modification = prev_code or bool(_workspace_listing())
new_build = use_code and not is_modification
want_plan = cfg.get("plan_mode", True) and (
new_build or (not use_code and enhance.needs_plan(req.content))
)
if want_rag or want_plan or use_code:
yield _sse("status", {"message": "Préparation du contexte…"})
from ..mcp_client import manager as mcp_manager
# La préparation peut déclencher un CHARGEMENT de modèle (plan, embed)
# qui dure plusieurs minutes sur un gros modèle. Sans battement de
# cœur, le silence SSE fait couper la connexion par le reverse proxy.
prep = asyncio.ensure_future(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, code=use_code,
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),
mcp_manager.tool_definitions(),
))
while True:
try:
memories, plan, code_model, mcp_tools = await asyncio.wait_for(
asyncio.shield(prep), timeout=10.0
)
break
except TimeoutError:
yield _sse("ping", {"status": "waiting"})
# Pannes MCP éventuelles : notice non bloquante dans le fil.
for mcp_notice in mcp_manager.notices():
yield _sse("notice", {"message": mcp_notice})
# Placement du modèle : dire tout de suite si l'inférence est hors GPU,
# au lieu de laisser l'utilisateur attribuer la lenteur à Loki.
placement = await _placement_notice(model)
if placement:
yield _sse("notice", {"message": placement})
# ── Consignes additionnelles du tour ──────────────────────────────
# Elles sont COLLECTÉES ici puis fusionnées dans l'UNIQUE message
# système (voir _merge_system plus bas). Beaucoup de templates
# (Gemma, Mistral…) lèvent « System message must be at the beginning »
# dès qu'un second message system apparaît : les empiler faisait
# échouer toute la requête en 400.
extras: list[str] = []
# La demande touche-t-elle au code / aux fichiers ? Calculé AVANT les
# injections : tout ce qui suit est conditionné à cette réponse.
is_code_like = use_code or msg_router.is_code_task(req.content)
needs_workspace = is_code_like or prev_code
if memories:
extras.append(
"Souvenirs pertinents d'anciennes sessions :\n"
+ "\n---\n".join(memories)
)
# État du workspace : indispensable pour travailler sur les fichiers,
# mais inutile pour une simple discussion. On l'injectait à CHAQUE
# message — un « bonjour » partait avec des milliers de jetons de
# contexte, donc une longue phase de traitement avant le 1er jeton.
listing = _workspace_listing() if needs_workspace else []
if listing:
recap, session_files = _session_code_context(history)
parts = ["Fichiers du workspace : " + ", ".join(listing)]
if session_files:
parts.append(
"Fichiers de la tâche en cours : " + ", ".join(session_files[:8])
)
extras.append("\n".join(parts))
# Session code restée en chemin agent : pousse le modèle à AGIR sur
# les fichiers au lieu de décrire les changements — cause fréquente de
# « l'agent s'arrête sans rien modifier » sur une reprise de code.
if prev_code and not use_code:
extras.append(
"Cette session travaille sur du code existant du workspace. "
"Pour toute demande de modification ou d'ajout : AGIS avec "
"les outils — code_task pour un changement multi-fichiers, "
"edit_file pour un changement ciblé, write_file pour un "
"nouveau fichier. Lis le fichier concerné avant de le "
"modifier, puis modifie-le RÉELLEMENT. Ne colle JAMAIS le "
"code corrigé dans ta réponse sans l'avoir écrit dans le "
"fichier."
)
# Skill : méthode experte injectée pour ce tour (jamais persistée).
if cfg.get("skills_enabled", True):
skill = skills.pick_skill(req.content)
if skill:
extras.append("Méthode à suivre pour cette tâche :\n" + skill["body"])
yield _sse("notice", {"message": f"📘 Méthode : {skill['title']}"})
# Ponytail : méthode « code minimal » injectée pour toute tâche de code
# (les deux chemins). Contre la sur-ingénierie qui casse les rendus.
if cfg.get("ponytail", True) and is_code_like:
extras.append(skills.PONYTAIL_GUIDANCE)
# Appli web : contraintes dures (fichier autonome, zéro dépendance
# externe, rendu réel) pour que ça marche vraiment hors-ligne.
want_web = is_code_like and skills.is_web_task(req.content)
if want_web:
extras.append(skills.WEBAPP_GUIDANCE)
# Mémoire en notes : en mode « always », on injecte les notes liées à la
# demande. Rien n'est deviné — ce sont des notes que l'agent a
# lui-même écrites, et le bloc dit explicitement de les ignorer si
# elles ne s'appliquent pas.
memory_mode = cfg.get("memory_mode", "ondemand")
# Mode off : les outils n'existent pas pour le modèle (principe AJEAN).
# En mode Plan (lecture seule), la consultation reste permise mais pas
# l'écriture d'une note.
memory_tools = (
[] if memory_mode == "off"
else ["memory_search"] if req.mode == "plan"
else ["memory_search", "memory_save"]
)
if memory_mode == "always":
block = memory_notes.recall_block(req.content)
if block:
extras.append(block)
if plan:
yield _sse("plan", {"steps": plan})
# Chemin « moteur code » : Aider gère la tâche de bout en bout.
if use_code:
instruction_plan = (
"\n\nPlan à suivre :\n"
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
if plan else ""
)
# Reprise : Aider ne voit que le message courant — on lui donne le
# récap de session et, à défaut de fichiers cités, ceux déjà
# touchés (« corrige les bugs » => il ouvre le bon fichier).
recap, session_files = _session_code_context(history)
extra = instruction_plan
if recap:
extra = f"\n\n{recap}" + extra
# Le moteur code (Aider) ne voit pas convo : on lui redonne Ponytail
# (et les contraintes web) directement dans la consigne.
if want_web:
extra = "\n\n" + skills.WEBAPP_GUIDANCE + extra
if cfg.get("ponytail", True):
extra = "\n\n" + skills.PONYTAIL_GUIDANCE + extra
code_files = _mentioned_files(req.content) or session_files[:8]
async for chunk in _code_stream(
req, code_model, extra=extra, plan=plan, files=code_files,
):
yield chunk
asyncio.create_task(memory.maybe_summarize(
req.session_id, model, options=run_opts, keep_alive=keep,
))
if cfg.get("rag_enabled", False):
last = db.list_messages(req.session_id)
answer = last[-1]["content"] if last else ""
asyncio.create_task(rag.index_exchange(
req.session_id, req.content, answer,
embed_model=cfg.get("embed_model"),
))
return
if plan:
extras.append(
"Plan à suivre pour cette demande, étape par étape :\n"
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
+ "\n\nTraite les étapes DANS L'ORDRE. Dès qu'une étape est "
"réellement accomplie, écris sur une ligne seule "
"« ✅ Étape N terminée » (N = son numéro) avant de passer à "
"la suivante. N'annonce jamais une étape terminée à l'avance."
)
# Fusion : un SEUL message système, en tête. Indispensable pour les
# templates qui refusent tout system ailleurs qu'en première position.
_merge_system(convo, extras)
final_content = ""
tools_meta: list[dict] = []
stats_meta: dict | None = None
thinking_meta = ""
error_message = ""
queue: asyncio.Queue[dict | None] = asyncio.Queue()
async def produce_events() -> None:
try:
async for event in run_agent(
model,
convo,
options=agent_config.ollama_options(cfg),
enabled_tools=agent_config.enabled_tool_names(cfg) + memory_tools,
confirm_shell=cfg.get("confirm_shell", True),
think=cfg.get("think", True),
keep_alive=cfg.get("keep_alive", "30m"),
mcp_tools=mcp_tools,
plan=plan,
):
await queue.put(event)
except Exception as exc:
logger.exception("Échec inattendu du flux de chat")
await queue.put(
{
"type": "error",
"message": f"Erreur interne du chat : {exc}",
}
)
finally:
await queue.put(None)
producer = asyncio.create_task(produce_events())
started = time.monotonic()
got_event = False
try:
while True:
try:
ev = await asyncio.wait_for(queue.get(), timeout=15.0)
except TimeoutError:
# Empêche OpenResty/Nginx/Cloudflare de fermer le SSE pendant
# le chargement parfois long d'un modèle Ollama.
yield _sse("ping", {"status": "waiting"})
# …et DIT ce qui se passe : sans ça, l'interface restait sur
# « Connexion à Ollama… » pendant tout le chargement d'un
# gros modèle, sans moyen de distinguer lenteur et blocage.
if not got_event:
yield _sse("status", {
"message": _loading_message(
model, time.monotonic() - started
)
})
continue
got_event = True
if ev is None:
break
etype = ev.pop("type")
if etype in (
"token",
"thinking",
"status",
"notice",
"tool_call",
"tool_result",
"tool_confirm",
"plan_step",
):
yield _sse(etype, ev)
elif etype == "error":
error_message = ev.get("message", "Erreur Ollama inconnue")
yield _sse("error", {"message": error_message})
elif etype == "final":
final_content = ev["content"]
tools_meta = ev["tools"]
stats_meta = ev.get("stats")
thinking_meta = ev.get("thinking", "")
finally:
if not producer.done():
producer.cancel()
with suppress(asyncio.CancelledError):
await producer
# Auto-critique : relecture éclair puis révision (option « Qualité + »).
if (
cfg.get("self_review", False)
and final_content
and not error_message
and not tools_meta
):
yield _sse("status", {"message": "Relecture de la réponse…"})
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})
yield _sse("notice", {"message": "Réponse révisée après auto-critique ✓"})
if final_content or tools_meta:
meta: dict = {}
if tools_meta:
meta["tools"] = tools_meta
if stats_meta:
meta["stats"] = stats_meta
if thinking_meta:
meta["thinking"] = thinking_meta
if plan:
meta["plan"] = plan
db.add_message(
req.session_id,
"assistant",
final_content,
model,
meta=meta or None,
)
yield _sse(
"done",
{
"content": final_content,
"tools": tools_meta,
"stats": stats_meta,
"error": error_message or None,
},
)
# Tâches d'arrière-plan : compression de l'historique + mémoire RAG.
asyncio.create_task(memory.maybe_summarize(
req.session_id, model, options=run_opts, keep_alive=keep,
))
if cfg.get("rag_enabled", False) and final_content:
asyncio.create_task(rag.index_exchange(
req.session_id, req.content, final_content,
embed_model=cfg.get("embed_model"),
))
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
)
-86
View File
@@ -1,86 +0,0 @@
"""Routes de lecture et mise à jour de la configuration de l'agent."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import agent_config
router = APIRouter(prefix="/api/config", tags=["config"])
class ConfigPatch(BaseModel):
"""Champs modifiables. DOIT couvrir agent_config.PROFILE_FIELDS.
Pydantic ignore silencieusement un champ non déclaré : un oubli ici fait
« sauter » le réglage sans la moindre erreur (c'était le cas de
keep_alive, skills_enabled et ponytail).
"""
system_prompt: str | None = None
temperature: float | None = None
top_p: float | None = None
top_k: int | None = None
max_tokens: int | None = None
num_ctx: int | None = None
num_gpu: int | None = None
num_batch: int | None = None
tools: dict[str, bool] | None = None
confirm_shell: bool | None = None
think: bool | None = None
code_model: str | None = None
plan_mode: bool | None = None
self_review: bool | None = None
rag_enabled: bool | None = None
embed_model: str | None = None
memory_mode: str | None = None
skills_enabled: bool | None = None
ponytail: bool | None = None
keep_alive: str | None = None
@router.get("")
async def get_config(model: str | None = None) -> dict:
return {
"config": agent_config.get_config(model),
"available_tools": agent_config.AVAILABLE_TOOLS,
}
@router.put("")
async def put_config(patch: ConfigPatch, model: str | None = None) -> dict:
cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model)
return {"config": cfg}
# ── Presets : jeux de réglages nommés, commutables (idée reprise d'AJEAN) ──
class PresetBody(BaseModel):
name: str
@router.get("/presets")
async def get_presets() -> dict:
return {"presets": agent_config.list_presets()}
@router.post("/presets")
async def post_preset(body: PresetBody, model: str | None = None) -> dict:
"""Enregistre la configuration courante sous un nom."""
try:
return {"presets": agent_config.save_preset(body.name, model)}
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/presets/apply")
async def apply_preset(body: PresetBody, model: str | None = None) -> dict:
"""Applique un preset à la configuration courante."""
cfg = agent_config.apply_preset(body.name, model)
if cfg is None:
raise HTTPException(404, "preset introuvable")
return {"config": cfg}
@router.delete("/presets")
async def remove_preset(name: str) -> dict:
return {"presets": agent_config.delete_preset(name)}
-105
View File
@@ -1,105 +0,0 @@
"""Routes du workspace : arborescence, contenu, téléchargement, suppression.
Toutes les routes acceptent un paramètre optionnel ``project`` : la requête
est alors re-racinée sur ``workspace/<projet>`` (même confinement).
"""
from __future__ import annotations
import os
import shutil
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from .. import tools as tools_mod
from ..tools import ToolError, _safe_path
router = APIRouter(prefix="/api/files", tags=["files"])
def _activate(project: str | None) -> None:
"""Pose le projet actif pour la requête (400 si nom invalide)."""
try:
tools_mod.set_project(project or None)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
def _tree(path: str, root: str) -> list[dict]:
"""Arborescence triée (dossiers d'abord) de la racine active."""
items = []
for name in sorted(os.listdir(path)):
if name.startswith("."):
continue
full = os.path.join(path, name)
rel = os.path.relpath(full, root)
if os.path.isdir(full):
items.append({"name": name, "path": rel, "type": "dir",
"children": _tree(full, root)})
else:
items.append({"name": name, "path": rel, "type": "file",
"size": os.path.getsize(full)})
# Dossiers avant fichiers
items.sort(key=lambda x: (x["type"] != "dir", x["name"]))
return items
@router.get("")
async def list_files(project: str | None = None) -> dict:
_activate(project)
root = tools_mod.active_root()
return {"tree": _tree(root, root)}
@router.get("/content")
async def file_content(path: str, project: str | None = None) -> dict:
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc))
if not os.path.isfile(target):
raise HTTPException(404, "fichier introuvable")
with open(target, "r", encoding="utf-8", errors="replace") as f:
return {"path": path, "content": f.read()}
@router.get("/download")
async def download_file(path: str, project: str | None = None) -> FileResponse:
"""Télécharge un fichier en conservant le confinement au workspace."""
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
if not os.path.isfile(target):
raise HTTPException(404, "fichier introuvable")
return FileResponse(
target,
filename=os.path.basename(target),
media_type="application/octet-stream",
)
@router.delete("")
async def delete_file(path: str, project: str | None = None) -> dict:
"""Supprime un fichier ou un dossier (récursif) de la racine active.
Même confinement que le téléchargement (`_safe_path`) ; la racine active
est refusée. Les dotfiles (.git…) ne sont jamais listés par
l'arborescence, donc inaccessibles depuis l'UI.
"""
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
if os.path.abspath(target) == tools_mod.active_root():
raise HTTPException(400, "suppression de la racine 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}
-106
View File
@@ -1,106 +0,0 @@
"""Routes Git du workspace : historique, diff, retour arrière.
Le workspace est un dépôt git (Aider commite chaque modification). Ces routes
donnent à l'UI un panneau Git : voir les commits, leur diff, et annuler une
modification. Toutes les commandes sont exécutées DANS le workspace.
"""
from __future__ import annotations
import os
import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import tools
from ..coder import ensure_git
from ..config import settings
router = APIRouter(prefix="/api/git", tags=["git"])
def _git(
*args: str, timeout: int = 15, project: str | None = None
) -> subprocess.CompletedProcess:
"""Exécute une commande git dans le workspace ou le projet demandé."""
root = os.path.abspath(settings.workspace_dir)
if project:
if not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
root = os.path.join(root, project)
ensure_git(root)
return subprocess.run(
["git", *args],
cwd=root,
capture_output=True,
text=True,
timeout=timeout,
)
@router.get("/log")
async def git_log(limit: int = 40, project: str | None = None) -> dict:
"""Historique des commits (hash court, message, auteur, date, nb fichiers)."""
fmt = "%h%x1f%s%x1f%an%x1f%ar%x1f%H"
proc = _git(
"log", f"-{max(1, min(limit, 200))}", f"--pretty=format:{fmt}",
project=project,
)
if proc.returncode != 0:
# Dépôt sans commit encore.
return {"commits": []}
commits = []
for line in proc.stdout.splitlines():
parts = line.split("\x1f")
if len(parts) != 5:
continue
short, subject, author, when, full = parts
# Nombre de fichiers touchés par ce commit.
stat = _git("show", "--stat", "--oneline", "--format=", full,
project=project)
files = [l for l in stat.stdout.splitlines() if "|" in l]
commits.append({
"hash": short,
"full_hash": full,
"subject": subject,
"author": author,
"when": when,
"files_changed": len(files),
})
return {"commits": commits}
@router.get("/diff")
async def git_diff(hash: str | None = None, project: str | None = None) -> dict:
"""Diff d'un commit (hash) ou des modifications non commitées si absent."""
if hash:
if not hash.replace("-", "").isalnum():
raise HTTPException(400, "hash invalide")
proc = _git("show", "--no-color", hash, project=project)
else:
proc = _git("diff", "--no-color", "HEAD", project=project)
if proc.returncode != 0:
raise HTTPException(404, "diff introuvable")
# Borne la taille pour ne pas noyer l'UI.
return {"diff": proc.stdout[:200_000]}
class RevertRequest(BaseModel):
hash: str
project: str | None = None
@router.post("/revert")
async def git_revert(req: RevertRequest) -> dict:
"""Annule un commit en créant un commit inverse (git revert)."""
h = req.hash.strip()
if not h.replace("-", "").isalnum():
raise HTTPException(400, "hash invalide")
proc = _git("revert", "--no-edit", h, timeout=30, project=req.project)
if proc.returncode != 0:
# Conflit ou commit introuvable : on nettoie un éventuel revert en cours.
_git("revert", "--abort", project=req.project)
detail = (proc.stderr or proc.stdout).strip()[:300]
raise HTTPException(409, f"revert impossible : {detail}")
return {"ok": True, "message": f"commit {h[:7]} annulé"}
-54
View File
@@ -1,54 +0,0 @@
"""Routes de configuration des serveurs MCP (catalogue + toggles)."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..mcp_client import CATALOG, get_mcp_state, manager, set_mcp_state
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def _payload() -> dict:
state = get_mcp_state()
statuses = manager.statuses()
return {
"servers": [
{
"id": sid,
"label": entry["label"],
"description": entry["description"],
"url_param": entry["url_param"],
"env_params": entry["env_params"],
"enabled": state[sid]["enabled"],
"params": state[sid]["params"],
**statuses[sid],
}
for sid, entry in CATALOG.items()
]
}
@router.get("")
async def list_servers() -> dict:
return _payload()
class McpUpdate(BaseModel):
enabled: bool
params: dict = {}
@router.put("/{sid}")
async def update_server(sid: str, req: McpUpdate) -> dict:
if sid not in CATALOG:
raise HTTPException(404, "serveur inconnu")
set_mcp_state(sid, enabled=req.enabled, params=req.params)
return _payload()
@router.post("/{sid}/test")
async def test_server(sid: str) -> dict:
if sid not in CATALOG:
raise HTTPException(404, "serveur inconnu")
return await manager.test_server(sid)
-197
View File
@@ -1,197 +0,0 @@
"""Routes liées à Ollama : statut de connexion, modèles, téléchargement."""
from __future__ import annotations
import asyncio
import json
import httpx
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import agent_config
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api", tags=["ollama"])
# État en mémoire des préchargements. Loki est lancé avec un worker unique ; ce
# suivi permet au navigateur d'interroger une route courte pendant que le long
# chargement Ollama continue sans maintenir la requête HTTP initiale ouverte.
_warm_states: dict[str, dict[str, str]] = {}
_warm_tasks: dict[str, asyncio.Task[None]] = {}
@router.get("/status")
async def status() -> dict:
"""État de la connexion Ollama (point vert/rouge de la barre supérieure)."""
try:
version = await ollama.ping()
return {
"connected": True,
"host": ollama.host,
"version": version.get("version"),
"default_model": settings.default_model,
}
except (httpx.HTTPError, OSError) as exc:
return {
"connected": False,
"host": ollama.host,
"error": str(exc),
"default_model": settings.default_model,
}
@router.get("/models")
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_cached()
except (httpx.HTTPError, OSError):
return {"models": []}
models = []
for m in raw:
details = m.get("details", {})
size_go = round(m.get("size", 0) / 1_000_000_000, 1)
models.append(
{
"name": m.get("name"),
"size_go": size_go,
"parameter_size": details.get("parameter_size"),
"quantization": details.get("quantization_level"),
"family": details.get("family"),
}
)
return {"models": models, "default": settings.default_model}
class WarmRequest(BaseModel):
name: str
keep_alive: str = "30m"
async def _placement_of(name: str) -> dict:
"""Où le modèle vient-il d'être chargé ? (GPU / CPU / mixte), via /api/ps."""
try:
for m in await ollama.ps():
if m.get("name") == name or m.get("model") == name:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0:
return {}
pct = int(vram / size * 100)
where = "gpu" if pct >= 99 else "cpu" if pct <= 1 else "mixte"
return {"processor": where, "gpu_percent": str(pct)}
except (httpx.HTTPError, OSError):
pass
return {}
async def _warm_in_background(name: str, keep_alive: str, options: dict | None) -> None:
try:
await ollama.warm(name, keep_alive, options)
state = {"state": "loaded"}
state.update(await _placement_of(name))
_warm_states[name] = state
except Exception as exc:
_warm_states[name] = {
"state": "error",
"error": f"préchargement impossible : {str(exc)[:500]}",
}
def start_model_warm(name: str, keep_alive: str) -> None:
"""Démarre au plus une tâche de préchargement par modèle.
Les options de génération du modèle (num_ctx, num_batch, num_gpu…) sont
envoyées au préchargement pour qu'Ollama charge exactement le runner que le
chat utilisera — évite un rechargement complet au premier message.
"""
current = _warm_tasks.get(name)
if current and not current.done():
return
cfg = agent_config.get_config(name)
options = agent_config.ollama_options(cfg)
_warm_states[name] = {"state": "loading"}
task = asyncio.create_task(_warm_in_background(name, keep_alive, options))
_warm_tasks[name] = task
def forget(done: asyncio.Task[None]) -> None:
if _warm_tasks.get(name) is done:
_warm_tasks.pop(name, None)
task.add_done_callback(forget)
@router.post("/models/warm", status_code=202)
async def warm_model(req: WarmRequest) -> dict:
"""Démarre le préchargement sans exposer sa durée au reverse proxy."""
name = req.name.strip()
if not name:
raise HTTPException(400, "nom de modèle vide")
start_model_warm(name, req.keep_alive)
return {"warming": name, "state": _warm_states[name]["state"]}
@router.get("/models/warm/status")
async def warm_status(name: str = Query(min_length=1)) -> dict:
"""État court du préchargement : idle, loading, loaded ou error."""
return _warm_states.get(name.strip(), {"state": "idle"})
@router.get("/models/loaded")
async def loaded_models() -> dict:
"""Modèles actuellement chargés en mémoire + placement GPU/CPU (/api/ps)."""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return {"loaded": []}
result = []
for m in loaded:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
result.append({
"name": m.get("name") or m.get("model"),
"on_gpu": bool(size and vram >= size * 0.99),
"gpu_percent": int(vram / size * 100) if size else 0,
})
return {"loaded": result}
class PullRequest(BaseModel):
name: str
class DeleteModelRequest(BaseModel):
name: str
@router.delete("/models")
async def delete_model(req: DeleteModelRequest) -> dict:
"""Supprime explicitement un modèle de l'instance Ollama."""
if not req.name.strip():
raise HTTPException(400, "nom de modèle vide")
try:
await ollama.delete_model(req.name.strip())
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:500] or str(exc)
raise HTTPException(502, f"Ollama : {detail}") from exc
except (httpx.HTTPError, OSError) as exc:
raise HTTPException(502, f"Ollama injoignable : {exc}") from exc
return {"deleted": req.name.strip()}
@router.post("/models/pull")
async def pull_model(req: PullRequest) -> StreamingResponse:
"""Télécharge un modèle en streamant la progression (SSE)."""
async def event_stream():
try:
async for chunk in ollama.pull_model(req.name):
yield f"data: {json.dumps(chunk)}\n\n"
except (httpx.HTTPError, OSError) as exc:
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
-60
View File
@@ -1,60 +0,0 @@
"""Routes des projets : sous-dossiers de premier niveau du workspace."""
from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import coder, tools
from ..config import settings
router = APIRouter(prefix="/api/projects", tags=["projects"])
def _root() -> str:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return root
def _count_files(path: str) -> int:
total = 0
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
total += sum(1 for f in filenames if not f.startswith("."))
return total
@router.get("")
async def list_projects() -> dict:
root = _root()
projects = []
root_files = 0
for name in sorted(os.listdir(root)):
full = os.path.join(root, name)
if name.startswith("."):
continue
if os.path.isdir(full):
projects.append({"name": name, "files": _count_files(full)})
else:
root_files += 1
return {"projects": projects, "root_files": root_files}
class CreateProject(BaseModel):
name: str
@router.post("", status_code=201)
async def create_project(req: CreateProject) -> dict:
name = req.name.strip()
if not tools.PROJECT_NAME.match(name):
raise HTTPException(400, "nom de projet invalide (a-z, 0-9, - et _)")
target = os.path.join(_root(), name)
if os.path.exists(target):
raise HTTPException(400, "ce projet existe déjà")
os.makedirs(target)
# Dépôt git par projet : commits Aider + onglet Git propres au projet.
coder.ensure_git(target)
return {"name": name}
-60
View File
@@ -1,60 +0,0 @@
"""Routes de gestion des sessions de conversation."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import db, tools
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
class CreateSession(BaseModel):
title: str = "Nouvelle session"
model: str | None = None
project: str | None = None
class UpdateSession(BaseModel):
title: str | None = None
project: str | None = None # "" = retour à la racine du workspace
@router.get("")
async def get_sessions() -> dict:
return {"sessions": db.list_sessions()}
@router.post("")
async def post_session(req: CreateSession) -> dict:
if req.project and not tools.PROJECT_NAME.match(req.project):
raise HTTPException(400, "nom de projet invalide")
return db.create_session(req.title, req.model, req.project or None)
@router.get("/{sid}")
async def get_one(sid: str) -> dict:
session = db.get_session(sid)
if not session:
raise HTTPException(404, "session introuvable")
return {"session": session, "messages": db.list_messages(sid)}
@router.patch("/{sid}")
async def patch_session(sid: str, req: UpdateSession) -> dict:
if not db.get_session(sid):
raise HTTPException(404, "session introuvable")
if req.title is not None:
db.rename_session(sid, req.title)
if req.project is not None:
project = req.project or None
if project and not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
db.set_session_project(sid, project)
return {"ok": True}
@router.delete("/{sid}")
async def remove_session(sid: str) -> dict:
db.delete_session(sid)
return {"ok": True}
-33
View File
@@ -1,33 +0,0 @@
"""Route d'exécution d'une commande shell validée par l'utilisateur.
La boucle agentique n'exécute jamais run_shell elle-même quand la confirmation
est active : elle émet un événement `tool_confirm`. Le client affiche la
commande, et c'est seulement après clic explicite de l'utilisateur que cette
route exécute la commande (confinée au workspace).
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..tools import ToolError, run_shell
router = APIRouter(prefix="/api/shell", tags=["shell"])
class ShellRequest(BaseModel):
command: str
@router.post("/run")
async def run(req: ShellRequest) -> dict:
"""Exécute la commande validée et renvoie sa sortie."""
try:
result = run_shell(req.command)
except ToolError as exc:
raise HTTPException(400, str(exc))
return {
"command": req.command,
"exit_code": result["exit_code"],
"output": result["output"],
}
-232
View File
@@ -1,232 +0,0 @@
"""Statistiques système temps réel : CPU, RAM, GPU/VRAM (barre supérieure)."""
from __future__ import annotations
import asyncio
import shutil
import time
import httpx
import psutil
from fastapi import APIRouter
from ..config import settings
from ..ollama_client import ollama
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.
# TTL volontairement > à la cadence de sondage, sinon le cache n'absorbe rien.
_GPU_CACHE_TTL = 12.0
_gpu_cache: dict = {"at": 0.0, "value": None}
# Même logique pour la vue matériel (sous-processus nvidia-smi plus lourd).
_HW_CACHE_TTL = 12.0
_hw_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).
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,
"--query-gpu=utilization.gpu,memory.used,memory.total,name",
"--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
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(","))
value = {
"name": name,
"util_pct": float(util),
"vram_used_mb": float(used),
"vram_total_mb": float(total),
}
except Exception:
value = None
_gpu_cache.update(at=time.monotonic(), value=value)
return value
@router.get("/stats")
async def stats() -> dict:
"""CPU %, RAM et GPU/VRAM courants."""
cpu_pct = psutil.cpu_percent(interval=None)
mem = psutil.virtual_memory()
gpu = await _gpu_stats()
return {
"cpu_pct": cpu_pct,
"ram_used_go": round(mem.used / 1_000_000_000, 1),
"ram_total_go": round(mem.total / 1_000_000_000, 1),
"ram_pct": mem.percent,
"gpu": gpu,
}
# Battement groupé : absorbe les rafales (plusieurs onglets, reconnexions)
# sans multiplier les sous-processus ni les connexions vers Ollama.
_PULSE_TTL = 2.5
_pulse_cache: dict = {"at": 0.0, "value": None}
async def _loaded_models() -> list[dict]:
"""Modèles chargés en mémoire + placement GPU (vide si Ollama muet)."""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return []
out = []
for m in loaded:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
out.append({
"name": m.get("name") or m.get("model"),
"on_gpu": bool(size and vram >= size * 0.99),
"gpu_percent": int(vram / size * 100) if size else 0,
})
return out
@router.get("/pulse")
async def pulse() -> dict:
"""Battement unique : statut Ollama + ressources + modèles chargés.
Remplace trois sondages séparés (statut, stats, modèles chargés) par UNE
requête : moins de réveils, moins de sous-processus et moins de connexions
vers Ollama quand l'application est simplement ouverte sans être utilisée.
"""
if time.monotonic() - _pulse_cache["at"] < _PULSE_TTL and _pulse_cache["value"]:
return _pulse_cache["value"]
# Les deux appels Ollama partent ensemble : latence = le plus lent, pas la
# somme (important quand Ollama est sur une autre machine).
version, loaded = await asyncio.gather(
ollama.ping(), _loaded_models(), return_exceptions=True
)
connected = not isinstance(version, BaseException)
mem = psutil.virtual_memory()
value = {
"status": {
"connected": connected,
"host": ollama.host,
"version": version.get("version") if connected else None,
"default_model": settings.default_model,
**({} if connected else {"error": str(version)[:200]}),
},
"stats": {
"cpu_pct": psutil.cpu_percent(interval=None),
"ram_used_go": round(mem.used / 1_000_000_000, 1),
"ram_total_go": round(mem.total / 1_000_000_000, 1),
"ram_pct": mem.percent,
"gpu": await _gpu_stats(),
},
"loaded": [] if isinstance(loaded, BaseException) else loaded,
}
_pulse_cache.update(at=time.monotonic(), value=value)
return value
async def _all_local_gpus() -> list[dict]:
"""Tous les GPU NVIDIA visibles depuis le CONTENEUR Loki (peut être vide).
Mis en cache : le panneau Matériel se rafraîchit en continu tant qu'il est
ouvert, et chaque appel lançait un sous-processus nvidia-smi.
"""
if not _NVIDIA_SMI:
return []
if time.monotonic() - _hw_cache["at"] < _HW_CACHE_TTL:
return _hw_cache["value"] or []
try:
proc = await asyncio.create_subprocess_exec(
_NVIDIA_SMI,
"--query-gpu=index,name,memory.total,memory.used,utilization.gpu",
"--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
gpus = []
for line in out.decode().strip().splitlines():
idx, name, total, used, util = (p.strip() for p in line.split(","))
gpus.append({
"index": int(idx), "name": name,
"vram_total_mb": float(total), "vram_used_mb": float(used),
"util_pct": float(util),
})
_hw_cache.update(at=time.monotonic(), value=gpus)
return gpus
except Exception:
_hw_cache.update(at=time.monotonic(), value=[])
return []
@router.get("/hardware")
async def hardware() -> dict:
"""Vue matériel : GPU vu par Loki vs GPU réellement utilisé par Ollama."""
# 1) Ce que voit le conteneur Loki (nvidia-smi) + override éventuel.
local_gpus = await _all_local_gpus()
override = None
if settings.gpu_vram_mb > 0:
override = {
"name": settings.gpu_name or "GPU déclaré (GPU_VRAM_MB)",
"vram_total_mb": settings.gpu_vram_mb,
}
# 2) Ce qu'Ollama utilise réellement (modèles chargés + placement VRAM/CPU).
ollama_info: dict = {"host": ollama.host, "connected": False, "running": []}
try:
version = await ollama.ping()
ollama_info["connected"] = True
ollama_info["version"] = version.get("version")
except (httpx.HTTPError, OSError) as exc:
ollama_info["error"] = str(exc)[:200]
if ollama_info["connected"]:
try:
for m in await ollama.ps():
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0:
where = "inconnu"
elif vram >= size * 0.99:
where = "GPU"
elif vram <= size * 0.01:
where = "CPU"
else:
where = "mixte"
ollama_info["running"].append({
"name": m.get("name") or m.get("model"),
"processor": where,
"gpu_percent": int(vram / size * 100) if size else 0,
"size_mb": round(size / 1_000_000),
"vram_mb": round(vram / 1_000_000),
})
except (httpx.HTTPError, OSError):
pass
# Ollama est-il sur la même machine que Loki ? (heuristique sur l'hôte)
host = ollama.host.lower()
is_local = any(h in host for h in ("localhost", "127.0.0.1", "host.docker.internal"))
return {
"loki_gpus": local_gpus,
"gpu_override": override,
"ollama": ollama_info,
"ollama_is_local": is_local,
"note": (
"Loki ne voit pas directement le GPU d'un Ollama distant : il déduit "
"le placement (GPU/CPU) depuis les modèles chargés. Déclare GPU_VRAM_MB "
"pour l'auto-réglage si Ollama tourne sur une autre machine."
),
}