mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
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:
87 files changed
-18730
No files matched your search
Whitespace-only changes.
@@ -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"},
|
||||
)
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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)}
|
||||
@@ -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}
|
||||
@@ -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é"}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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."
|
||||
),
|
||||
}
|
||||
Reference in new issue
Block a user