diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 92a575b..0000000 --- a/.dockerignore +++ /dev/null @@ -1,26 +0,0 @@ -# Contrôle de version -.git -.gitignore - -# Python -**/__pycache__ -**/*.pyc -backend/.venv -backend/venv - -# Node / build -frontend/node_modules -frontend/dist -**/.vite -frontend/tsconfig.tsbuildinfo - -# Données & workspace de runtime (montés en volume) -data -workspace - -# Environnement / IDE -.env -.env.local -.idea -.vscode -**/.DS_Store diff --git a/.env.example b/.env.example deleted file mode 100644 index 6a0b6ab..0000000 --- a/.env.example +++ /dev/null @@ -1,26 +0,0 @@ -# ── Loki — configuration ──────────────────────────────────────────────── -# Hôte Ollama. En Docker, host.docker.internal pointe vers la machine hôte. -# En local (hors Docker), utilise http://localhost:11434 -OLLAMA_HOST=http://host.docker.internal:11434 - -# Modèle sélectionné par défaut au démarrage -DEFAULT_MODEL=llama3.1:8b - -# Dossier de travail de l'agent (monté en volume dans le conteneur) -WORKSPACE_DIR=/workspace - -# Base SQLite (sessions, messages, config) -DATA_DIR=/data - -# Port de l'application (identique dedans/dehors). 8717 par défaut. -PORT=8717 - -# Optionnel : URL d'une instance SearxNG pour l'outil web_search. -# Si vide, web_search utilise DuckDuckGo (sans clé d'API). -SEARX_URL= - -# Optionnel : VRAM du GPU (en Mo) pour l'auto-réglage, si Ollama tourne sur une -# autre machine (la détection nvidia-smi ne voit pas un GPU distant). -# Ex. 12000 pour une carte 12 Go. 0 = détection automatique. -GPU_VRAM_MB=0 -GPU_NAME= diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 3168cf3..0000000 --- a/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# ── Étape 1 : build du frontend ───────────────────────────────────────── -FROM node:20-alpine AS frontend -WORKDIR /app/frontend -COPY frontend/package.json frontend/package-lock.json* ./ -RUN npm install -COPY frontend/ ./ -RUN npm run build - -# ── Étape 2 : runtime backend (sert aussi le front statique) ──────────── -FROM python:3.12-slim AS runtime -WORKDIR /app - -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - WORKSPACE_DIR=/workspace \ - DATA_DIR=/data \ - PORT=8717 - -# curl pour le HEALTHCHECK ; git pour le moteur code (commits Aider) ; -# nodejs/npm pour les serveurs MCP lancés via npx (Playwright, Context7…) -RUN apt-get update && apt-get install -y --no-install-recommends curl git nodejs npm \ - && rm -rf /var/lib/apt/lists/* - -COPY backend/requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt mcp-server-fetch - -COPY backend/ ./backend/ -# Frontend compilé servi en statique par FastAPI -COPY --from=frontend /app/frontend/dist ./backend/static - -RUN mkdir -p /workspace /data - -# Marqueur de version (git sha court), injecté par le workflow de build. -ARG LOKI_VERSION=dev -ENV LOKI_VERSION=${LOKI_VERSION} - -# NB : on tourne en root par défaut pour rester compatible avec les volumes -# montés d'Unraid (/mnt/user/appdata/...), souvent détenus par root. Pour -# durcir, surcharge l'utilisateur côté compose (ex. user: "99:100"). - -EXPOSE 8717 -WORKDIR /app/backend - -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD curl -fsS "http://localhost:${PORT}/api/health" || exit 1 - -CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/agent.py b/backend/app/agent.py deleted file mode 100644 index c456896..0000000 --- a/backend/app/agent.py +++ /dev/null @@ -1,517 +0,0 @@ -"""Boucle agentique : tool-calling itératif au-dessus d'Ollama. - -L'agent dialogue avec le modèle ; quand celui-ci demande un outil, on l'exécute, -on réinjecte le résultat, et on reboucle jusqu'à une réponse finale (ou la -limite d'itérations). La fonction est un générateur asynchrone d'événements -relayés tels quels au client via SSE. - -Événements émis : - token {content} — fragment de texte de l'agent - tool_call {name, args} — début d'exécution d'un outil - tool_result {name, args, summary, status} - final {content, tools} — réponse complète + récap des outils -""" -from __future__ import annotations - -import asyncio -import json -import re -from typing import AsyncIterator - -import httpx - -from . import coder -from .ollama_client import OllamaError, ollama -from .tools import TOOL_DEFINITIONS, ToolError, run_tool - -MAX_ITERATIONS = 6 -MAX_TOOL_REPAIR_ATTEMPTS = 2 - -# Coupe-circuit de réflexion : au-delà de cette taille de pensée SANS aucun -# contenu ni appel d'outil, on interrompt la génération en cours au lieu -# d'attendre l'épuisement du budget num_predict (minutes sur un gros modèle). -_MAX_THINKING_CHARS = 12_000 - -# Élagage : au-delà de cette taille, un résultat d'outil des itérations -# passées est tronqué. Les gros payloads (MCP 8 Ko, shell 4 Ko) saturaient le -# contexte en un seul tour long — Ollama tronquait alors silencieusement le -# DÉBUT de la conversation, faisant « oublier » la consigne au modèle. -_PRUNE_KEEP_CHARS = 350 - - -def _prune_old_tool_results(convo: list[dict], before_index: int) -> None: - """Compacte les résultats d'outils déjà consommés par le modèle. - - Seuls les messages ``tool`` antérieurs à ``before_index`` (donc traités - lors d'une itération précédente) sont tronqués ; le dernier lot reste - intact, c'est celui auquel le modèle répond. - """ - for msg in convo[:before_index]: - content = msg.get("content", "") - if msg.get("role") == "tool" and len(content) > _PRUNE_KEEP_CHARS: - msg["content"] = ( - content[:_PRUNE_KEEP_CHARS] - + "… [résultat archivé — déjà traité, ne pas redemander]" - ) - - -def _tools_not_supported(exc: OllamaError) -> bool: - """Détecte un modèle incapable de function calling. - - Deux cas : - - Ollama refuse explicitement (« does not support tools ») ; - - Ollama n'arrive pas à dériver un parseur d'appels d'outils du template - du modèle (« Unable to generate parser for this template ») — fréquent - sur des modèles exotiques dont le template Jinja lève une exception. - Dans les deux cas, on retombe sur une conversation simple, sans outils. - """ - message = str(exc).lower() - return ( - "does not support tools" in message - or "does not support tool" in message - or "unable to generate parser for this template" in message - or "automatic parser generation failed" in message - ) - - -def _thinking_not_supported(exc: OllamaError) -> bool: - """Le modèle (ou son template) refuse le paramètre ``think``.""" - message = str(exc).lower() - return "does not support thinking" in message or "thinking is not supported" in message - - -def _invalid_tool_arguments(exc: OllamaError) -> bool: - message = str(exc).lower() - return ( - "invalid tool call arguments" in message - or "unexpected end of json" in message - or "failed to parse tool" in message - ) - - -def _parse_args(raw) -> dict: - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - try: - return json.loads(raw) - except json.JSONDecodeError: - return {} - return {} - - -# Marqueur d'avancement du plan émis par le modèle (« ✅ Étape 2 terminée »). -# Tolérant : coche/croix optionnelle, mot « étape » optionnel, numéro requis. -_STEP_DONE = re.compile( - r"(?:✅|✔|☑|\[x\])\s*(?:étape|etape|step)?\s*(\d{1,2})" - r"|(?:étape|etape|step)\s*(\d{1,2})\s*(?:terminée|terminee|faite|ok|✅|✔)", - re.I, -) - - -def _scan_plan_done(text: str, plan_len: int, already: set[int]) -> list[int]: - """Indices (0-based) de nouvelles étapes annoncées terminées dans ``text``.""" - fresh: list[int] = [] - for m in _STEP_DONE.finditer(text): - num = m.group(1) or m.group(2) - idx = int(num) - 1 - if 0 <= idx < plan_len and idx not in already: - already.add(idx) - fresh.append(idx) - return fresh - - -async def run_agent( - model: str, - convo: list[dict], - *, - options: dict | None = None, - enabled_tools: list[str] | None = None, - confirm_shell: bool = True, - think: bool = True, - keep_alive: str | None = None, - mcp_tools: list[dict] | None = None, - plan: list[str] | None = None, -) -> AsyncIterator[dict]: - # enabled_tools=None -> tous les outils ; liste vide -> aucun outil. - if enabled_tools is None: - tools = list(TOOL_DEFINITIONS) - elif enabled_tools: - tools = [ - t for t in TOOL_DEFINITIONS if t["function"]["name"] in enabled_tools - ] - else: - tools = [] - # Outils MCP des serveurs activés (déjà résolus par l'appelant). - tools.extend(mcp_tools or []) - if not tools: - tools = None - collected: list[dict] = [] - text_parts: list[str] = [] - thinking_parts: list[str] = [] - active_tools = tools - tool_fallback_used = False - tool_repair_attempts = 0 - request_options = dict(options or {}) - # On n'envoie ``think`` que pour le DÉSACTIVER (False) ; laissé à None, le - # modèle garde son comportement par défaut. Repli si le modèle le refuse. - request_think: bool | None = None if think else False - # Mode réflexion : la pensée consomme le budget num_predict. Sans marge, - # le modèle « pense » tout son quota et s'arrête sans agir ni répondre. - if request_think is None and think: - request_options["num_predict"] = max( - int(request_options.get("num_predict") or 0), 6144 - ) - # Compteur d'itérations où le modèle n'a produit QUE du raisonnement. - thinking_only_strikes = 0 - - # Suivi de l'avancement du plan : étapes déjà annoncées terminées. - plan_len = len(plan or []) - plan_done: set[int] = set() - - # Métriques cumulées sur tous les appels Ollama du tour agentique : Ollama - # les renvoie dans le chunk final (done=true) de chaque génération. - stats = {"eval_count": 0, "eval_duration": 0, "prompt_eval_count": 0} - - def _accumulate(chunk: dict) -> None: - stats["eval_count"] += chunk.get("eval_count") or 0 - stats["eval_duration"] += chunk.get("eval_duration") or 0 - stats["prompt_eval_count"] += chunk.get("prompt_eval_count") or 0 - - # Index du début du dernier lot de résultats d'outils (à préserver). - last_batch_start = 0 - - try: - for _ in range(MAX_ITERATIONS): - content_buf = "" - thinking_buf = "" - thinking_status_sent = False - tool_calls: list[dict] = [] - - # Compacte les résultats d'outils des itérations antérieures : - # garde le contexte court, la consigne système jamais tronquée. - if last_batch_start: - _prune_old_tool_results(convo, last_batch_start) - - # Un modèle peut savoir discuter sans supporter les outils. Ollama - # refuse alors la requête entière : on retente une fois en chat simple. - while True: - try: - async for chunk in ollama.chat( - model, - convo, - tools=active_tools, - options=request_options, - think=request_think, - keep_alive=keep_alive, - stream=True, - ): - msg = chunk.get("message", {}) - token = msg.get("content", "") - thinking = msg.get("thinking", "") - if thinking: - thinking_buf += thinking - # Diffuse le raisonnement en direct pour l'afficher - # dans le panneau repliable du chat. - yield {"type": "thinking", "content": thinking} - if not thinking_status_sent: - thinking_status_sent = True - yield {"type": "status", "message": "Réflexion…"} - # Pensée interminable sans production : on coupe la - # génération maintenant — la relance (plus bas) - # remet le modèle au travail immédiatement. - if ( - len(thinking_buf) > _MAX_THINKING_CHARS - and not content_buf - and not tool_calls - ): - break - if token: - content_buf += token - yield {"type": "token", "content": token} - # Coche les étapes du plan annoncées terminées, en - # direct. Scan borné : uniquement quand le token - # porte un marqueur plausible. - if plan_len and any( - c in token for c in ("✅", "✔", "☑", "tape", "step", "]") - ): - for idx in _scan_plan_done(content_buf, plan_len, plan_done): - yield {"type": "plan_step", "index": idx, "status": "done"} - if msg.get("tool_calls"): - tool_calls.extend(msg["tool_calls"]) - if chunk.get("done"): - _accumulate(chunk) - break - break - except OllamaError as exc: - if ( - active_tools - and tool_repair_attempts < MAX_TOOL_REPAIR_ATTEMPTS - and not content_buf - and not tool_calls - and _invalid_tool_arguments(exc) - ): - tool_repair_attempts += 1 - request_options["num_predict"] = max( - int(request_options.get("num_predict", 0)), 4096 - ) - thinking_buf = "" - # NB : on réinjecte ce rappel en `user`, pas en `system`. - # Beaucoup de templates (Gemma, Mistral…) lèvent - # « System message must be at the beginning » dès qu'un - # message system apparaît ailleurs qu'en tête, ce qui - # ferait échouer toute la requête en 400. - convo.append( - { - "role": "user", - "content": ( - "L'appel d'outil précédent contenait un JSON " - "tronqué. Réessaie immédiatement avec des arguments " - "JSON valides. Pour un fichier long, utilise write_file " - "en plusieurs appels : overwrite puis append, avec des " - "morceaux courts et complets." - ), - } - ) - yield { - "type": "notice", - "message": ( - "Appel d'outil tronqué : nouvelle tentative " - f"{tool_repair_attempts}/{MAX_TOOL_REPAIR_ATTEMPTS}." - ), - } - continue - if ( - active_tools - and not tool_fallback_used - and not content_buf - and not tool_calls - and _tools_not_supported(exc) - ): - active_tools = None - tool_fallback_used = True - yield { - "type": "notice", - "message": ( - "Ce modèle ne supporte pas les outils ; " - "réponse en mode conversation simple." - ), - } - continue - if request_think is not None and _thinking_not_supported(exc): - # Le modèle n'accepte pas qu'on désactive sa réflexion : - # on retire le paramètre et on relance. - request_think = None - continue - raise - - # Itération « réflexion seule » : ni contenu, ni appel d'outil — - # le modèle a brûlé sa génération à penser. Sans relance, la - # boucle s'arrêtait là et la tâche restait inachevée. - if thinking_buf and not content_buf.strip() and not tool_calls: - thinking_parts.append(thinking_buf) - thinking_only_strikes += 1 - if thinking_only_strikes == 1: - convo.append({ - "role": "user", - "content": ( - "Tu n'as produit que du raisonnement, sans réponse " - "ni action. Continue la tâche MAINTENANT : appelle " - "l'outil suivant ou donne ta réponse finale, sans " - "réfléchir davantage." - ), - }) - yield {"type": "status", "message": "Relance après réflexion…"} - continue - # Deuxième fois : la réflexion est coupée pour finir la tâche. - request_think = False - yield { - "type": "notice", - "message": ( - "Réflexion désactivée pour ce tour : le modèle " - "n'avançait plus." - ), - } - continue - - assistant_turn: dict = {"role": "assistant", "content": content_buf} - if thinking_buf: - # Gardée pour l'affichage (panneau repliable), mais JAMAIS - # renvoyée au modèle : la re-soumettre gonflait le contexte à - # chaque itération des longues tâches. - thinking_parts.append(thinking_buf) - if tool_calls: - assistant_turn["tool_calls"] = tool_calls - convo.append(assistant_turn) - if content_buf.strip(): - text_parts.append(content_buf.strip()) - - if not tool_calls: - break - - # Les résultats du lot qui suit commencent ici : ils restent - # intacts au prochain tour, les précédents seront compactés. - last_batch_start = len(convo) - - # Exécution des outils demandés, puis réinjection des résultats. - awaiting_confirmation = False - for tc in tool_calls: - fn = tc.get("function", {}) - name = fn.get("name", "") - args = _parse_args(fn.get("arguments")) - - yield {"type": "tool_call", "name": name, "args": args} - - # run_shell est sensible : on demande validation au lieu d'exécuter. - if name == "run_shell" and confirm_shell: - command = args.get("command", "") - record = { - "name": name, - "args": args, - "summary": "validation requise", - "status": "pending", - } - collected.append(record) - yield {"type": "tool_confirm", "name": name, "command": command} - convo.append( - { - "role": "tool", - "tool_name": name, - "content": json.dumps( - { - "ok": False, - "status": "pending", - "message": "Commande en attente de validation " - "de l'utilisateur. N'exécute rien d'autre.", - }, - ensure_ascii=False, - ), - } - ) - awaiting_confirmation = True - continue - - # code_task : délégué au moteur code (Aider), long -> thread. - if name == "code_task": - from .tools import active_root - code_model = await coder.pick_code_model(model) - result = await asyncio.to_thread( - coder.run_code_task, - args.get("instruction", ""), - code_model, - args.get("files") or [], - active_root(), - ) - summary = result.get("summary", "terminé") - status = "ok" if result.get("ok") else "error" - record = {"name": name, "args": {"instruction": args.get("instruction", "")}, - "summary": summary, "status": status} - collected.append(record) - yield {"type": "tool_result", **record} - convo.append({ - "role": "tool", - "tool_name": name, - "content": json.dumps( - {k: result.get(k) for k in ("ok", "summary", "files", "text")}, - ensure_ascii=False, - ), - }) - continue - - # Outils MCP : dispatch asynchrone vers le serveur concerné. - if name.startswith("mcp_"): - from .mcp_client import manager as mcp_manager - mcp_res = await mcp_manager.call_tool(name, args) - status = "ok" if mcp_res["ok"] else "error" - record = {"name": name, "args": args, - "summary": mcp_res["summary"], "status": status} - collected.append(record) - yield {"type": "tool_result", **record} - convo.append({ - "role": "tool", - "tool_name": name, - "content": json.dumps( - {"ok": mcp_res["ok"], "content": mcp_res["content"]}, - ensure_ascii=False, - ), - }) - continue - - try: - result = run_tool(name, args) - summary = result.get("summary", "terminé") - status = result.get("_status", "ok") - except ToolError as exc: - result = {"ok": False, "error": str(exc)} - summary = str(exc) - status = "error" - - record = {"name": name, "args": args, "summary": summary, "status": status} - collected.append(record) - yield {"type": "tool_result", **record} - - convo.append( - { - "role": "tool", - "tool_name": name, - "content": json.dumps(result, ensure_ascii=False), - } - ) - - # Une commande shell attend une validation : on interrompt la boucle. - if awaiting_confirmation: - # Laisse le modèle conclure son tour (message d'attente). - final_chunk = "" - async for chunk in ollama.chat( - model, convo, options=request_options, - think=request_think, keep_alive=keep_alive, stream=True - ): - tok = chunk.get("message", {}).get("content", "") - if tok: - final_chunk += tok - yield {"type": "token", "content": tok} - if chunk.get("done"): - _accumulate(chunk) - break - if final_chunk.strip(): - text_parts.append(final_chunk.strip()) - break - except OllamaError as exc: - # Échec signalé par Ollama (HTTP ou ligne d'erreur dans le flux) : - # souvent un débordement mémoire / contexte trop grand. On l'expose. - yield {"type": "error", "message": f"Ollama : {exc}"} - return - except (httpx.HTTPError, OSError) as exc: - yield { - "type": "error", - "message": f"Impossible de joindre Ollama ({ollama.host}) : {exc}", - } - return - - final_content = "\n\n".join(text_parts).strip() - if not final_content and not collected: - yield { - "type": "error", - "message": ( - "Le modèle a terminé sans renvoyer de texte (il n'a produit que " - "du raisonnement). Désactive « Mode réflexion » dans les Réglages, " - "ou essaie un modèle de chat plus récent." - ), - } - return - - eval_secs = stats["eval_duration"] / 1e9 - final_stats = { - "eval_count": stats["eval_count"], - "prompt_eval_count": stats["prompt_eval_count"], - "tokens_per_sec": ( - round(stats["eval_count"] / eval_secs, 1) if eval_secs > 0 else None - ), - } - yield { - "type": "final", - "content": final_content, - "tools": collected, - "stats": final_stats, - "thinking": "\n\n".join(thinking_parts).strip(), - } diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py deleted file mode 100644 index 5c40307..0000000 --- a/backend/app/agent_config.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Profil de configuration de l'agent : invite système, génération, outils. - -Persisté en base sous la clé `agent`. Fournit les valeurs par défaut et la -fusion avec ce qui est stocké, pour rester robuste aux montées de version. -""" -from __future__ import annotations - -import re - -from . import db - -CONFIG_KEY = "agent" -MODEL_PROFILES_KEY = "model_profiles" -PROFILE_STATE_KEY = "model_profiles_state" -PROFILE_VERSION = 7 - -DEFAULT_SYSTEM_PROMPT = ( - "Tu es Loki, un assistant de développement local agentique. Tu disposes " - "d'outils pour lire, écrire et lister des fichiers dans le workspace, et " - "d'un moteur code (code_task) pour toute création ou modification de code " - "multi-fichiers : privilégie code_task pour les tâches de programmation. " - "Utilise les outils pour accomplir les tâches concrètement, puis réponds " - "de façon concise en français. Pour MODIFIER un fichier existant, utilise " - "edit_file (search/replace) plutôt que de tout réécrire : lis d'abord le " - "fichier avec read_file, puis copie dans `search` l'extrait EXACT à changer " - "(quelques lignes suffisent, l'indentation est tolérée). N'emploie " - "write_file en overwrite QUE pour créer un nouveau fichier ou en cas de " - "refonte complète. Quand tu appelles write_file, fournis toujours `path` et " - "`content` ; pour un fichier long, appelle write_file en plusieurs morceaux " - "(overwrite puis append) afin de toujours produire un JSON valide. Tu ne " - "peux écrire QUE dans le workspace : jamais de chemin absolu ni de `../` " - "qui en sortent. Après avoir écrit un fichier, propose un aperçu. " - "Formate TOUJOURS tes réponses en Markdown : titres, listes, gras pour les " - "points clés, tableaux si pertinent, et surtout des blocs de code avec le " - "langage indiqué (```python, ```html…) pour tout extrait de code ou commande." -) - -# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés -# par défaut, conformément à la maquette. -AVAILABLE_TOOLS = [ - "read_file", "write_file", "edit_file", "list_dir", "grep_search", - "run_check", "code_task", "web_search", "run_shell", -] -SENSITIVE_TOOLS = {"run_shell"} -DEFAULT_TOOL_STATE = { - "read_file": True, - "write_file": True, - "edit_file": True, - "list_dir": True, - "grep_search": True, - "run_check": True, - "code_task": True, - "web_search": False, - "run_shell": False, -} - -GENERATION_FIELDS = { - "temperature", - "top_p", - "top_k", - "max_tokens", - "num_ctx", - "num_gpu", - "num_batch", -} -PROFILE_FIELDS = { - "system_prompt", - "tools", - "confirm_shell", - "think", - "code_model", - "plan_mode", - "self_review", - "rag_enabled", - "embed_model", - "memory_mode", - "skills_enabled", - "ponytail", - "keep_alive", - *GENERATION_FIELDS, -} - -DEFAULT_GENERATION: dict = { - "temperature": 0.7, - "top_p": 0.9, - "top_k": 40, - "max_tokens": 2048, - # 16k : les tâches longues (recherche, multi-fichiers) saturaient 4-8k en - # un seul tour et Ollama tronquait la consigne. Le cache KV d'un 16k reste - # raisonnable (~1-3 Go selon modèle) ; réduis num_ctx dans Configuration - # si la VRAM déborde, ou active OLLAMA_KV_CACHE_TYPE=q8_0 côté Ollama. - "num_ctx": 16384, - "num_gpu": -1, - "num_batch": 256, -} - -RTX_3060_GEMMA4_PROFILE: dict = { - **DEFAULT_GENERATION, - "max_tokens": 4096, - "num_ctx": 16384, - # num_gpu = -1 : laisse Ollama placer le plus de couches possible sur le GPU - # (auto-fit, comme `ollama run`). Forcer un nombre de couches qui ne tient pas - # en VRAM fait basculer toute l'inférence sur le CPU. - "num_gpu": -1, -} - -DEFAULT_CONFIG: dict = { - "system_prompt": DEFAULT_SYSTEM_PROMPT, - **DEFAULT_GENERATION, - "tools": dict(DEFAULT_TOOL_STATE), - # Demander une validation utilisateur avant toute commande shell. - "confirm_shell": True, - # Mode réflexion des modèles « thinking ». Désactiver (False) évite qu'un - # modèle ne renvoie que du raisonnement sans réponse finale. - "think": True, - # Modèle utilisé par le moteur code : "auto" = meilleur modèle code installé - # (qwen-coder, deepseek-coder…), sinon le modèle de chat courant. - "code_model": "auto", - # Plan-puis-exécute : décompose les demandes complexes en étapes. - "plan_mode": True, - # Auto-critique : une passe de relecture/révision avant la réponse finale. - "self_review": False, - # Mémoire long-terme (RAG) ENTRE sessions, via un modèle d'embedding. - # Désactivée par défaut : chaque discussion ne se souvient que d'elle-même - # (résumé + messages récents). Sinon une ancienne demande sans rapport (ex. - # « appli sport ») ressurgit dans une nouvelle discussion (ex. « jeu - # d'échecs ») et embrouille les petits modèles. Réactivable dans Réglages. - "rag_enabled": False, - "embed_model": "auto", - # Mémoire en notes Markdown écrites par l'agent (voir memory_notes.py). - # "ondemand" : les outils existent, mais rien n'est injecté sans que le - # modèle le demande — aucun risque de voir ressurgir une demande sans - # rapport, contrairement à l'ancien RAG vectoriel. - "memory_mode": "ondemand", - # Skills : méthodes expertes injectées automatiquement selon la tâche. - "skills_enabled": True, - # Ponytail : méthode « code minimal » (anti sur-ingénierie) injectée pour - # toute tâche de code. Voir github.com/DietrichGebert/ponytail. - "ponytail": True, - # Durée de maintien du modèle en VRAM (préchargement). "0" = décharge - # aussitôt, "-1" = jamais, "30m" = 30 minutes. - "keep_alive": "30m", -} - - -def _default_generation(model: str | None) -> dict: - if model and model.split(":", 1)[0].lower() == "gemma4": - return dict(RTX_3060_GEMMA4_PROFILE) - return dict(DEFAULT_GENERATION) - - -def _migrate_profiles() -> None: - state = db.get_config_value(PROFILE_STATE_KEY) or {} - if state.get("version", 0) >= PROFILE_VERSION: - return - profiles = db.get_config_value(MODEL_PROFILES_KEY) or {} - gemma_profile = { - **RTX_3060_GEMMA4_PROFILE, - **profiles.get("gemma4:12b", {}), - } - if gemma_profile.get("max_tokens", 0) <= 2048: - gemma_profile["max_tokens"] = 4096 - profiles["gemma4:12b"] = gemma_profile - # v4 : un ancien profil pouvait forcer num_gpu sur un nombre de couches codé - # en dur (ex. 49), ce qui basculait l'inférence sur le CPU quand ça ne tenait - # pas en VRAM. On repasse en auto (-1) pour laisser Ollama placer les couches. - for prof in profiles.values(): - if prof.get("num_gpu", -1) is not None and prof.get("num_gpu", -1) > 0: - prof["num_gpu"] = -1 - # v6 : contexte 16k par défaut. On ne touche qu'aux profils restés sur un - # ancien défaut (4096/8192) — une valeur personnalisée est respectée. - for prof in profiles.values(): - if prof.get("num_ctx") in (4096, 8192): - prof["num_ctx"] = 16384 - db.set_config_value(MODEL_PROFILES_KEY, profiles) - - # v7 : la mémoire inter-sessions (RAG) faisait ressurgir d'anciennes - # demandes sans rapport dans une nouvelle discussion. On la désactive une - # fois sur les installs existantes ; réactivable manuellement dans Réglages. - stored = db.get_config_value(CONFIG_KEY) - if stored and stored.get("rag_enabled"): - stored["rag_enabled"] = False - db.set_config_value(CONFIG_KEY, stored) - - db.set_config_value(PROFILE_STATE_KEY, {"version": PROFILE_VERSION}) - - -def _clean_tools(value: dict | None, fallback: dict | None = None) -> dict: - fallback = fallback or DEFAULT_TOOL_STATE - return { - name: bool((value or {}).get(name, fallback.get(name, DEFAULT_TOOL_STATE[name]))) - for name in AVAILABLE_TOOLS - } - - -def get_config(model: str | None = None) -> dict: - """Configuration complète, avec surcharge sauvegardée par modèle.""" - _migrate_profiles() - stored = db.get_config_value(CONFIG_KEY) or {} - cfg = {**DEFAULT_CONFIG, **stored} - cfg["tools"] = _clean_tools(stored.get("tools")) - if model: - profiles = db.get_config_value(MODEL_PROFILES_KEY) or {} - profile = profiles.get(model, {}) - cfg.update({**_default_generation(model), **profile}) - cfg["tools"] = _clean_tools(profile.get("tools"), cfg["tools"]) - return cfg - - -def save_config(patch: dict, model: str | None = None) -> dict: - """Sauvegarde tous les réglages, globalement ou pour un modèle.""" - clean = {k: v for k, v in patch.items() if v is not None} - cfg = {**get_config(model), **clean} - if "tools" in patch and patch["tools"]: - cfg["tools"] = _clean_tools(patch["tools"], cfg["tools"]) - - if model: - profiles = db.get_config_value(MODEL_PROFILES_KEY) or {} - profiles[model] = {field: cfg[field] for field in PROFILE_FIELDS} - db.set_config_value(MODEL_PROFILES_KEY, profiles) - else: - db.set_config_value(CONFIG_KEY, {field: cfg[field] for field in PROFILE_FIELDS}) - return get_config(model) - - -def runner_options(cfg: dict) -> dict: - """Sous-ensemble d'options qui détermine l'identité du runner Ollama. - - Ollama choisit son runner (processus de chargement du modèle) d'après - num_ctx / num_batch / num_gpu. TOUT appel au même modèle (plan, résumé, - agent…) doit envoyer ces mêmes valeurs, sinon Ollama recharge le modèle en - plein milieu d'un message — la cause principale des lenteurs observées. - """ - opts: dict = {"num_batch": cfg["num_batch"]} - # num_gpu n'est transmis que si l'utilisateur force explicitement un nombre - # de couches (≥ 0). En -1 (défaut), on laisse Ollama auto-ajuster l'offload - # GPU comme `ollama run` ; lui imposer une valeur peut le forcer sur le CPU. - if cfg.get("num_gpu", -1) >= 0: - opts["num_gpu"] = cfg["num_gpu"] - opts["main_gpu"] = 0 - # num_ctx n'est envoyé que s'il est défini (> 0), sinon défaut du modèle. - if cfg.get("num_ctx"): - opts["num_ctx"] = cfg["num_ctx"] - return opts - - -def ollama_options(cfg: dict) -> dict: - """Traduit la config en options de génération Ollama.""" - return { - **runner_options(cfg), - "temperature": cfg["temperature"], - "top_p": cfg["top_p"], - "top_k": cfg["top_k"], - "num_predict": cfg["max_tokens"], - } - - -# ── Presets : jeux de réglages nommés ──────────────────────────────────── -PRESETS_KEY = "config_presets" -_PRESET_NAME = re.compile(r"^[\w \-]{1,40}$") - - -def list_presets() -> list[str]: - return sorted((db.get_config_value(PRESETS_KEY) or {}).keys()) - - -def save_preset(name: str, model: str | None = None) -> list[str]: - """Fige la configuration courante sous ce nom (écrase si déjà pris).""" - name = (name or "").strip() - if not _PRESET_NAME.match(name): - raise ValueError("nom de preset invalide (lettres, chiffres, espaces, -)") - presets = db.get_config_value(PRESETS_KEY) or {} - cfg = get_config(model) - presets[name] = {field: cfg[field] for field in PROFILE_FIELDS if field in cfg} - db.set_config_value(PRESETS_KEY, presets) - return sorted(presets) - - -def apply_preset(name: str, model: str | None = None) -> dict | None: - """Applique un preset ; None s'il n'existe pas.""" - preset = (db.get_config_value(PRESETS_KEY) or {}).get((name or "").strip()) - if preset is None: - return None - return save_config(dict(preset), model) - - -def delete_preset(name: str) -> list[str]: - presets = db.get_config_value(PRESETS_KEY) or {} - presets.pop((name or "").strip(), None) - db.set_config_value(PRESETS_KEY, presets) - return sorted(presets) - - -def enabled_tool_names(cfg: dict) -> list[str]: - """Outils réellement proposables au modèle. - - Un outil activé dans la config mais INUTILISABLE (dépendance absente) ne - doit pas apparaître dans le contexte : sinon le modèle l'appelle, échoue, - et gâche un tour. Mieux vaut qu'il ne sache pas qu'il existe et emploie - directement write_file / edit_file. - """ - names = [name for name, on in cfg["tools"].items() if on] - - if "code_task" in names: - from . import coder - if not coder.available(): - names.remove("code_task") - - return names diff --git a/backend/app/autotune.py b/backend/app/autotune.py deleted file mode 100644 index c89e7b3..0000000 --- a/backend/app/autotune.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Auto-réglage : exploite au mieux chaque modèle selon le GPU disponible. - -Principe : - 1. on détecte la VRAM du GPU (nvidia-smi, sinon rocm-smi, sinon override env) ; - 2. on lit les métadonnées du modèle via Ollama (/api/show + /api/tags) : - contexte max, architecture (couches, têtes KV, dimension), taille sur disque ; - 3. on calcule la fenêtre de contexte (num_ctx) la plus grande qui tient en VRAM, - via une estimation du cache KV, puis un nombre de jetons de sortie cohérent. - -Tout est best-effort : si une info manque, on retombe sur des paliers prudents. -""" -from __future__ import annotations - -import shutil -import subprocess - -import httpx - -from .config import settings -from .ollama_client import ollama - -# Paliers de contexte « ronds » proposés (bornés par le contexte du modèle). -CTX_STEPS = [2048, 4096, 8192, 12288, 16384, 24576, 32768, 49152, 65536, 131072] - -# Marges VRAM (Mo) : OS/driver + buffers de calcul d'Ollama. -VRAM_OVERHEAD_MB = 1024 -VRAM_COMPUTE_BUFFER_MB = 768 - - -# ── Détection GPU ──────────────────────────────────────────────────────── -def detect_gpu() -> dict: - """Renvoie {available, name, vram_total_mb, source}.""" - # 1) Override explicite (env) — prioritaire, utile en conteneur sans GPU. - if settings.gpu_vram_mb > 0: - return { - "available": True, - "name": settings.gpu_name or "GPU (configuré)", - "vram_total_mb": settings.gpu_vram_mb, - "source": "env", - } - - # 2) NVIDIA - if shutil.which("nvidia-smi"): - try: - out = subprocess.run( - ["nvidia-smi", - "--query-gpu=name,memory.total", - "--format=csv,noheader,nounits"], - capture_output=True, text=True, timeout=5, - ) - if out.returncode == 0 and out.stdout.strip(): - line = out.stdout.strip().splitlines()[0] - name, total = [p.strip() for p in line.split(",")] - return { - "available": True, - "name": name, - "vram_total_mb": int(float(total)), - "source": "nvidia-smi", - } - except (OSError, ValueError, subprocess.SubprocessError): - pass - - # 3) AMD (ROCm) — best-effort - if shutil.which("rocm-smi"): - try: - out = subprocess.run( - ["rocm-smi", "--showmeminfo", "vram", "--csv"], - capture_output=True, text=True, timeout=5, - ) - if out.returncode == 0: - # Cherche le plus grand entier (octets) -> Mo - nums = [int(x) for x in out.stdout.replace(",", " ").split() - if x.isdigit()] - if nums: - return { - "available": True, - "name": "GPU AMD", - "vram_total_mb": max(nums) // (1024 * 1024), - "source": "rocm-smi", - } - except (OSError, ValueError, subprocess.SubprocessError): - pass - - return {"available": False, "name": "CPU", "vram_total_mb": 0, "source": "none"} - - -# ── Métadonnées modèle ─────────────────────────────────────────────────── -async def model_profile(model: str) -> dict: - """Extrait contexte max, archi (couches/têtes/dim), taille disque (Mo).""" - profile = { - "context_length": None, - "block_count": None, - "head_count": None, - "head_count_kv": None, - "embedding_length": None, - "size_mb": None, - "parameter_size": None, - "quantization": None, - } - try: - show = await ollama.show(model) - except (httpx.HTTPError, OSError): - return profile - - info = show.get("model_info", {}) or {} - arch = info.get("general.architecture", "") - - def pick(*suffixes): - for s in suffixes: - key = f"{arch}.{s}" if arch else s - if key in info: - return info[key] - for k, v in info.items(): - if k.endswith(s): - return v - return None - - profile["context_length"] = pick("context_length") - profile["block_count"] = pick("block_count") - profile["head_count"] = pick("attention.head_count") - profile["head_count_kv"] = pick("attention.head_count_kv") - profile["embedding_length"] = pick("embedding_length") - - details = show.get("details", {}) or {} - profile["parameter_size"] = details.get("parameter_size") - profile["quantization"] = details.get("quantization_level") - - # Taille sur disque via /api/tags - try: - for m in await ollama.list_models(): - if m.get("name") == model: - profile["size_mb"] = int(m.get("size", 0)) // (1024 * 1024) - break - except (httpx.HTTPError, OSError): - pass - - return profile - - -def _kv_bytes_per_token(p: dict) -> int | None: - """Estimation des octets de cache KV par jeton (KV en f16).""" - blocks = p.get("block_count") - kv_heads = p.get("head_count_kv") - heads = p.get("head_count") - emb = p.get("embedding_length") - if not all((blocks, kv_heads, heads, emb)): - return None - head_dim = emb / heads - # 2 (clé+valeur) * couches * têtes_kv * dim_tête * 2 octets (f16) - return int(2 * blocks * kv_heads * head_dim * 2) - - -def _round_ctx(candidate: int, ctx_max: int | None) -> int: - cap = ctx_max or CTX_STEPS[-1] - best = CTX_STEPS[0] - for step in CTX_STEPS: - if step <= candidate and step <= cap: - best = step - # Si le modèle plafonne bas, respecte son contexte max. - return min(best, cap) - - -# ── Recommandation ─────────────────────────────────────────────────────── -async def recommend(model: str) -> dict: - gpu = detect_gpu() - prof = await model_profile(model) - ctx_max = prof.get("context_length") - - rationale: list[str] = [] - - if not gpu["available"]: - # GPU non détecté CÔTÉ LOKI : Ollama tourne probablement sur une autre - # machine. On ne force surtout PAS num_ctx (0 = défaut du modèle), sinon - # Ollama chargerait une instance distincte qui déborderait sur le CPU. - num_ctx = 0 - rationale.append( - "GPU non détecté côté Loki (Ollama distant ?) — contexte laissé au " - "défaut du modèle pour rester sur le GPU. Déclare GPU_VRAM_MB pour " - "un réglage précis." - ) - else: - vram = gpu["vram_total_mb"] - model_mb = prof.get("size_mb") or _fallback_model_mb(prof) - budget = vram - model_mb - VRAM_OVERHEAD_MB - VRAM_COMPUTE_BUFFER_MB - kv_per_tok = _kv_bytes_per_token(prof) - - if budget <= 256: - num_ctx = _round_ctx(2048, ctx_max) - rationale.append("VRAM insuffisante pour les poids — contexte minimal.") - elif kv_per_tok: - tokens_fit = int(budget * 1024 * 1024 / kv_per_tok) - num_ctx = _round_ctx(tokens_fit, ctx_max) - rationale.append( - f"{vram} Mo VRAM − {model_mb} Mo poids → budget KV " - f"{budget} Mo (~{kv_per_tok // 1024} Ko/jeton)." - ) - else: - # Archi inconnue : paliers selon le budget VRAM restant. - num_ctx = _round_ctx(_tier_ctx(budget), ctx_max) - rationale.append("Archi modèle incomplète — estimation par paliers.") - - # Jetons de sortie : moitié du contexte, borné. Si num_ctx=0 (auto), on - # garde une valeur raisonnable sans la réduire. - max_tokens = 2048 if num_ctx == 0 else max(512, min(num_ctx // 2, 8192)) - - return { - "gpu": gpu, - "model": model, - "model_profile": { - "context_length": ctx_max, - "parameter_size": prof.get("parameter_size"), - "quantization": prof.get("quantization"), - "size_mb": prof.get("size_mb"), - }, - "recommended": {"num_ctx": num_ctx, "max_tokens": max_tokens}, - "rationale": " ".join(rationale), - } - - -async def placement(model: str) -> dict: - """Lit /api/ps : le modèle est-il chargé sur GPU, CPU, ou un mix ?""" - try: - loaded = await ollama.ps() - except (httpx.HTTPError, OSError): - return {"loaded": False} - - for m in loaded: - if m.get("name") == model or m.get("model") == model: - size = m.get("size", 0) or 0 - size_vram = m.get("size_vram", 0) or 0 - if size <= 0: - where = "inconnu" - elif size_vram >= size * 0.99: - where = "gpu" - elif size_vram <= size * 0.01: - where = "cpu" - else: - where = "mixte" - pct = int(size_vram / size * 100) if size else 0 - return {"loaded": True, "where": where, "gpu_percent": pct, - "size_mb": size // (1024 * 1024)} - return {"loaded": False} - - -def _fallback_model_mb(prof: dict) -> int: - """Estime la taille des poids si /api/tags n'a rien donné.""" - ps = (prof.get("parameter_size") or "").upper().replace("B", "") - try: - billions = float(ps) - except ValueError: - billions = 8.0 - # ~0.6 Go/milliard en Q4, approximation prudente. - return int(billions * 600) - - -def _tier_ctx(budget_mb: int) -> int: - if budget_mb >= 12000: - return 32768 - if budget_mb >= 8000: - return 16384 - if budget_mb >= 5000: - return 12288 - if budget_mb >= 3000: - return 8192 - if budget_mb >= 1500: - return 4096 - return 2048 diff --git a/backend/app/bench.py b/backend/app/bench.py deleted file mode 100644 index c0f658d..0000000 --- a/backend/app/bench.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Benchmark intégré : évalue objectivement chaque modèle installé. - -Cinq mini-épreuves (~30-60 s au total) qui mesurent ce qui compte pour Loki : -appel d'outil, code exécutable, respect des consignes, extraction JSON, -respect d'un format. Score /100, stocké en base et affiché dans l'UI. -""" -from __future__ import annotations - -import asyncio -import json -import re -import subprocess -import sys -import tempfile -import time -from typing import AsyncIterator - -import httpx - -from . import db -from .ollama_client import OllamaError, ollama - -BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}} - - -async def _ask(model: str, prompt: str, *, system: str = "", - tools: list | None = None, num_predict: int = 400) -> dict: - """Un appel modèle ; renvoie {text, tool_calls}.""" - messages = [] - if system: - messages.append({"role": "system", "content": system}) - messages.append({"role": "user", "content": prompt}) - text, calls = "", [] - async for chunk in ollama.chat( - model, messages, tools=tools, - options={"temperature": 0, "num_predict": num_predict}, stream=True, - ): - msg = chunk.get("message", {}) - text += msg.get("content", "") - if msg.get("tool_calls"): - calls.extend(msg["tool_calls"]) - if chunk.get("done"): - break - return {"text": text.strip(), "tool_calls": calls} - - -def _extract_code(text: str) -> str: - m = re.search(r"```(?:python)?\s*(.*?)```", text, re.S) - return (m.group(1) if m else text).strip() - - -def _run_python(code: str, test: str) -> bool: - """Exécute code+test dans un sous-processus isolé (timeout 8 s).""" - with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f: - f.write(code + "\n" + test) - path = f.name - try: - proc = subprocess.run( - [sys.executable, "-I", path], - capture_output=True, timeout=8, - ) - return proc.returncode == 0 - except (subprocess.SubprocessError, OSError): - return False - - -# ── Les 5 épreuves (score 0-20 chacune) ────────────────────────────────── -async def _task_tool_call(model: str) -> tuple[int, str]: - tools = [{ - "type": "function", - "function": { - "name": "write_file", - "description": "Écrire un fichier", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "content": {"type": "string"}, - }, - "required": ["path", "content"], - }, - }, - }] - try: - r = await _ask( - model, - "Crée le fichier bonjour.txt contenant exactement le texte : salut", - system="Utilise l'outil write_file pour créer le fichier demandé.", - tools=tools, num_predict=200, - ) - except httpx.HTTPStatusError as exc: - if "does not support tools" in exc.response.text.lower(): - return 0, "outils non supportés par ce modèle" - raise - for tc in r["tool_calls"]: - fn = tc.get("function", {}) - if fn.get("name") == "write_file": - args = fn.get("arguments") or {} - if isinstance(args, str): - try: - args = json.loads(args) - except json.JSONDecodeError: - return 8, "appel d'outil aux arguments illisibles" - ok_path = "bonjour" in str(args.get("path", "")).lower() - ok_content = "salut" in str(args.get("content", "")).lower() - score = 10 + 5 * ok_path + 5 * ok_content - return score, "appel d'outil correct" if score == 20 else "appel partiel" - return 0, "aucun appel d'outil émis" - - -async def _task_code(model: str) -> tuple[int, str]: - r = await _ask( - model, - "Écris une fonction Python `somme_pairs(nombres)` qui renvoie la somme " - "des nombres pairs de la liste. Réponds UNIQUEMENT avec le code.", - num_predict=300, - ) - code = _extract_code(r["text"]) - if "def somme_pairs" not in code: - return 0, "fonction absente" - test = ( - "assert somme_pairs([1,2,3,4]) == 6\n" - "assert somme_pairs([]) == 0\n" - "assert somme_pairs([7,9]) == 0\n" - ) - return (20, "code correct (3/3 tests)") if _run_python(code, test) \ - else (6, "code présent mais tests échoués") - - -async def _task_instruction(model: str) -> tuple[int, str]: - r = await _ask( - model, - "Quelle est la capitale de la France ? Réponds en 3 mots maximum.", - num_predict=30, - ) - text = r["text"] - has_answer = "paris" in text.lower() - short = len(text.split()) <= 6 - score = 12 * has_answer + 8 * short - return score, f"réponse « {text[:40]} »" - - -async def _task_json(model: str) -> tuple[int, str]: - r = await _ask( - model, - 'Extrait les informations en JSON strict {"nom": ..., "ville": ...} ' - "depuis : « Marie habite à Lyon ». Réponds UNIQUEMENT avec le JSON.", - num_predict=80, - ) - m = re.search(r"\{.*\}", r["text"], re.S) - if not m: - return 0, "pas de JSON" - try: - data = json.loads(m.group(0)) - except json.JSONDecodeError: - return 5, "JSON invalide" - ok_nom = "marie" in str(data.get("nom", "")).lower() - ok_ville = "lyon" in str(data.get("ville", "")).lower() - score = 10 + 5 * ok_nom + 5 * ok_ville - detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle" - return score, detail - - -async def _task_format(model: str) -> tuple[int, str]: - r = await _ask( - model, - "Liste exactement 3 fruits, un par ligne, chaque ligne préfixée par « - ».", - num_predict=60, - ) - lines = [l for l in r["text"].splitlines() if l.strip().startswith("-")] - if len(lines) == 3: - return 20, "format exact" - if len(lines) >= 2: - return 10, f"{len(lines)} lignes au lieu de 3" - return 0, "format non respecté" - - -TASKS = [ - ("Appel d'outil", _task_tool_call), - ("Code exécutable", _task_code), - ("Consigne courte", _task_instruction), - ("Extraction JSON", _task_json), - ("Respect du format", _task_format), -] - - -async def run_bench(model: str) -> AsyncIterator[dict]: - """Exécute les 5 épreuves en streamant la progression, stocke le score.""" - total = 0 - details = [] - for name, fn in TASKS: - yield {"type": "task_start", "task": name} - task = asyncio.create_task(fn(model)) - try: - while not task.done(): - done, _ = await asyncio.wait({task}, timeout=10) - if not done: - # Empêche OpenResty/Nginx de fermer le SSE pendant une longue - # génération d'un gros modèle. - yield {"type": "heartbeat", "task": name} - score, detail = await task - except (OllamaError, httpx.HTTPError, OSError) as exc: - score, detail = 0, f"erreur : {str(exc)[:80]}" - except Exception as exc: - # Une épreuve défaillante ne doit pas couper silencieusement le SSE : - # elle vaut zéro et les autres épreuves continuent. - score, detail = 0, f"épreuve interrompue : {str(exc)[:80]}" - finally: - if not task.done(): - task.cancel() - total += score - details.append({"task": name, "score": score, "detail": detail}) - yield {"type": "task_done", "task": name, "score": score, "detail": detail} - - results = db.get_config_value(BENCH_KEY) or {} - results[model] = {"score": total, "details": details, "at": time.time()} - db.set_config_value(BENCH_KEY, results) - yield {"type": "done", "score": total, "details": details} - - -def get_scores() -> dict: - return db.get_config_value(BENCH_KEY) or {} diff --git a/backend/app/coder.py b/backend/app/coder.py deleted file mode 100644 index d8d70a0..0000000 --- a/backend/app/coder.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Moteur code : enveloppe Aider pour l'édition multi-fichiers fiable. - -Aider apporte ce qui fait la force de Claude Code/Codex : formats d'édition -diff/search-replace robustes (même avec de petits modèles), repo map, et -commits git automatiques dans le workspace. - -L'API Python d'Aider n'étant pas officiellement stable, la version est FIGÉE -dans requirements.txt (aider-chat==0.86.2) et tout l'import est local à la -fonction (l'app démarre même si Aider est absent). -""" -from __future__ import annotations - -import os -import subprocess -import threading - -from .config import settings - -# Aider utilise le répertoire courant : un seul run à la fois. -_RUN_LOCK = threading.Lock() - - -def _confine(root: str, files: list[str] | None) -> list[str]: - """Résout les fichiers demandés en restant confiné au workspace. - - Écarte silencieusement tout chemin absolu ou toute remontée `../` qui - sortirait du workspace : le moteur code ne doit jamais toucher au disque - hors du workspace de la discussion. - """ - confined: list[str] = [] - for f in files or []: - target = os.path.abspath(os.path.join(root, f)) - if target == root or target.startswith(root + os.sep): - confined.append(target) - return confined - - -def ensure_git(root: str) -> None: - """Initialise un dépôt git dans le workspace (requis pour les commits Aider).""" - os.makedirs(root, exist_ok=True) - if os.path.isdir(os.path.join(root, ".git")): - return - subprocess.run(["git", "init", "-q"], cwd=root, check=False) - subprocess.run(["git", "config", "user.email", "loki@local"], cwd=root, check=False) - subprocess.run(["git", "config", "user.name", "Loki"], cwd=root, check=False) - - -def available() -> bool: - """Aider est-il installé ?""" - try: - import aider # noqa: F401 - return True - except ImportError: - return False - - -# Familles spécialisées code, par ordre de préférence. -_CODE_MODEL_HINTS = ( - "qwen3-coder", "qwen2.5-coder", "deepseek-coder", "codestral", "devstral", - "codegemma", "codellama", "starcoder", "coder", -) - - -async def pick_code_model(current: str, preference: str | None = None) -> str: - """Choisit le modèle pour les tâches de code. - - - préférence explicite (config code_model != "auto") -> respectée ; - - sinon, si un modèle spécialisé code est installé, on le prend (le plus - gros d'abord) : un petit modèle code bat un généraliste sur ce terrain ; - - sinon, on garde le modèle courant. - """ - if preference and preference != "auto": - return preference - - from .ollama_client import ollama - try: - installed = await ollama.list_models_cached() - except Exception: - return current - - candidates: list[tuple[int, int, str]] = [] # (rang_hint, -taille, nom) - for m in installed: - name = (m.get("name") or "").lower() - for rank, hint in enumerate(_CODE_MODEL_HINTS): - if hint in name: - candidates.append((rank, -(m.get("size") or 0), m["name"])) - break - - if not candidates: - return current - candidates.sort() - return candidates[0][2] - - -def run_code_task( - instruction: str, - model: str, - files: list[str] | None = None, - root: str | None = None, -) -> dict: - """Exécute une tâche de code via Aider (synchrone — lancer dans un thread). - - Renvoie {ok, text, files, commit, commit_message, summary}. - """ - if not available(): - return { - "ok": False, - "summary": "moteur code indisponible (aider non installé)", - "text": "", "files": [], "commit": None, - } - - from aider.coders import Coder - from aider.io import InputOutput - from aider.models import Model - - root = os.path.abspath(root or settings.workspace_dir) - ensure_git(root) - - # Aider parle à Ollama via litellm : on pointe vers notre instance. - os.environ["OLLAMA_API_BASE"] = settings.ollama_host - - with _RUN_LOCK: - prev_cwd = os.getcwd() - os.chdir(root) - try: - io = InputOutput(yes=True, pretty=False, fancy_input=False) - coder = Coder.create( - main_model=Model(f"ollama_chat/{model}"), - io=io, - fnames=_confine(root, files), - auto_commits=True, - stream=False, - use_git=True, - suggest_shell_commands=False, - detect_urls=False, - ) - text = coder.run(instruction) or "" - edited = sorted(coder.aider_edited_files or []) - commit = getattr(coder, "last_aider_commit_hash", None) - commit_msg = getattr(coder, "last_aider_commit_message", None) - except Exception as exc: # aider peut lever des erreurs variées - return { - "ok": False, - "summary": f"échec moteur code : {exc}", - "text": "", "files": [], "commit": None, - } - finally: - os.chdir(prev_cwd) - - summary = f"{len(edited)} fichier(s) modifié(s)" - if commit: - summary += f" · commit {str(commit)[:7]}" - return { - "ok": True, - "text": text.strip(), - "files": edited, - "commit": commit, - "commit_message": commit_msg, - "summary": summary, - } diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index 2314578..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Configuration de l'application, chargée depuis l'environnement.""" -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """Réglages globaux de Loki (surchargés par variables d'environnement).""" - - ollama_host: str = "http://host.docker.internal:11434" - default_model: str = "gemma4:12b" - workspace_dir: str = "/workspace" - data_dir: str = "/data" - port: int = 8080 - - # Marqueur de build injecté à la construction de l'image (git sha court). - # Permet de vérifier que l'image déployée est bien à jour. - loki_version: str = "dev" - - # Override manuel de la VRAM (Mo) si la détection GPU échoue dans le - # conteneur (utile quand Ollama tourne sur une autre machine). - gpu_vram_mb: int = 0 - gpu_name: str = "" - - model_config = SettingsConfigDict(env_file=".env", extra="ignore") - - -settings = Settings() diff --git a/backend/app/db.py b/backend/app/db.py deleted file mode 100644 index c0afdcd..0000000 --- a/backend/app/db.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Persistance SQLite : sessions et messages. - -On utilise sqlite3 de la bibliothèque standard (zéro dépendance). Les écritures -sont rapides ; un verrou protège l'accès concurrent depuis les routes async. -""" -from __future__ import annotations - -import json -import os -import sqlite3 -import threading -import time -import uuid - -from .config import settings - -_LOCK = threading.Lock() -_DB_PATH = os.path.join(settings.data_dir, "loki.db") - - -def _connect() -> sqlite3.Connection: - os.makedirs(settings.data_dir, exist_ok=True) - conn = sqlite3.connect(_DB_PATH, check_same_thread=False) - conn.row_factory = sqlite3.Row - return conn - - -def init_db() -> None: - """Crée les tables si elles n'existent pas.""" - with _LOCK, _connect() as conn: - conn.executescript( - """ - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - model TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ); - CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - role TEXT NOT NULL, - content TEXT NOT NULL, - model TEXT, - meta TEXT, - created_at REAL NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_messages_session - ON messages(session_id, created_at); - """ - ) - # Migration douce : ajoute la colonne meta aux bases antérieures. - cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")} - if "meta" not in cols: - conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT") - # Migration douce : résumé de conversation (mémoire compressée). - scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")} - if "summary" not in scols: - conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT") - # Migration douce : projet (sous-dossier de travail) de la session. - if "project" not in scols: - conn.execute("ALTER TABLE sessions ADD COLUMN project TEXT") - # Table clé/valeur pour la configuration de l'agent. - conn.execute( - "CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)" - ) - - -def _now() -> float: - return time.time() - - -# ── Sessions ───────────────────────────────────────────────────────────── -def create_session( - title: str, model: str | None, project: str | None = None -) -> dict: - sid = uuid.uuid4().hex - now = _now() - with _LOCK, _connect() as conn: - conn.execute( - "INSERT INTO sessions (id, title, model, project, created_at, updated_at)" - " VALUES (?, ?, ?, ?, ?, ?)", - (sid, title, model, project, now, now), - ) - return {"id": sid, "title": title, "model": model, "project": project, - "created_at": now, "updated_at": now, "message_count": 0} - - -def set_session_project(sid: str, project: str | None) -> None: - with _LOCK, _connect() as conn: - conn.execute( - "UPDATE sessions SET project = ? WHERE id = ?", (project, sid) - ) - - -def list_sessions() -> list[dict]: - with _LOCK, _connect() as conn: - rows = conn.execute( - """ - SELECT s.*, COUNT(m.id) AS message_count - FROM sessions s - LEFT JOIN messages m ON m.session_id = s.id - GROUP BY s.id - ORDER BY s.updated_at DESC - """ - ).fetchall() - return [dict(r) for r in rows] - - -def get_session(sid: str) -> dict | None: - with _LOCK, _connect() as conn: - row = conn.execute("SELECT * FROM sessions WHERE id = ?", (sid,)).fetchone() - return dict(row) if row else None - - -def rename_session(sid: str, title: str) -> None: - with _LOCK, _connect() as conn: - conn.execute( - "UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?", - (title, _now(), sid), - ) - - -def delete_session(sid: str) -> None: - with _LOCK, _connect() as conn: - conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,)) - conn.execute("DELETE FROM sessions WHERE id = ?", (sid,)) - - -def touch_session(sid: str) -> None: - with _LOCK, _connect() as conn: - conn.execute( - "UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), sid) - ) - - -# ── Messages ───────────────────────────────────────────────────────────── -def add_message( - sid: str, - role: str, - content: str, - model: str | None, - meta: dict | None = None, -) -> dict: - mid = uuid.uuid4().hex - now = _now() - meta_json = json.dumps(meta, ensure_ascii=False) if meta else None - with _LOCK, _connect() as conn: - conn.execute( - "INSERT INTO messages (id, session_id, role, content, model, meta, created_at)" - " VALUES (?, ?, ?, ?, ?, ?, ?)", - (mid, sid, role, content, model, meta_json, now), - ) - conn.execute( - "UPDATE sessions SET updated_at = ? WHERE id = ?", (now, sid) - ) - return {"id": mid, "session_id": sid, "role": role, "content": content, - "model": model, "meta": meta, "created_at": now} - - -def _row_to_message(row: sqlite3.Row) -> dict: - msg = dict(row) - msg["meta"] = json.loads(msg["meta"]) if msg.get("meta") else None - return msg - - -def list_messages(sid: str) -> list[dict]: - with _LOCK, _connect() as conn: - rows = conn.execute( - "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at", - (sid,), - ).fetchall() - return [_row_to_message(r) for r in rows] - - -def list_messages_for_model(sid: str) -> list[dict]: - """Historique épuré (role/content) destiné au contexte du modèle.""" - return [ - {"role": m["role"], "content": m["content"]} - for m in list_messages(sid) - ] - - -def set_session_summary(sid: str, summary: str) -> None: - with _LOCK, _connect() as conn: - conn.execute( - "UPDATE sessions SET summary = ? WHERE id = ?", (summary, sid) - ) - - -# ── Configuration (clé/valeur JSON) ────────────────────────────────────── -def get_config_value(key: str) -> dict | None: - with _LOCK, _connect() as conn: - row = conn.execute( - "SELECT value FROM config WHERE key = ?", (key,) - ).fetchone() - return json.loads(row["value"]) if row else None - - -def set_config_value(key: str, value: dict) -> None: - payload = json.dumps(value, ensure_ascii=False) - with _LOCK, _connect() as conn: - conn.execute( - "INSERT INTO config (key, value) VALUES (?, ?)" - " ON CONFLICT(key) DO UPDATE SET value = excluded.value", - (key, payload), - ) diff --git a/backend/app/enhance.py b/backend/app/enhance.py deleted file mode 100644 index f3ba615..0000000 --- a/backend/app/enhance.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Boosters de qualité pour petits modèles : plan-puis-exécute et auto-critique. - -- make_plan : décompose une demande complexe en 3-5 étapes courtes. Un petit - modèle qui suit un plan écrit réussit bien mieux qu'en improvisant. -- self_review : une passe de critique éclair sur la réponse, puis une révision - si des défauts sont trouvés (activable : coûte un peu de latence). -""" -from __future__ import annotations - -import logging -import re - -import httpx - -from .ollama_client import OllamaError, ollama - -logger = logging.getLogger(__name__) - -_PLAN_PROMPT = ( - "Découpe la demande en 3 à 5 étapes courtes et concrètes, une par ligne, " - "numérotées « 1. », « 2. »… Pas d'introduction, pas de conclusion, " - "UNIQUEMENT les étapes, en français." -) - -_PLAN_CODE_PROMPT = ( - "Tu es architecte logiciel. Établis un plan d'IMPLÉMENTATION concret pour " - "cette application, en 3 à 5 étapes courtes (une par ligne, numérotées " - "« 1. », « 2. »…).\n" - "- Étape 1 = ARCHITECTURE : privilégie UN SEUL fichier autonome (ex. " - "index.html avec le CSS et le JS intégrés, sans dépendance externe) ; ne " - "sépare en plusieurs fichiers que si c'est vraiment indispensable.\n" - "- Étapes suivantes = construire UNE fonctionnalité concrète et testable à " - "la fois (structure/affichage, puis interactions, puis logique).\n" - "Reste RÉALISTE et réalisable en une passe : pas de dépendance externe, pas " - "de bibliothèque à installer, pas de « moteur IA » complexe si ce n'est pas " - "explicitement demandé — une logique simple en JavaScript suffit. " - "Pas d'introduction ni de conclusion, UNIQUEMENT les étapes, en français." -) - -_CRITIQUE_PROMPT = ( - "Tu es un relecteur exigeant. Voici une demande et la réponse d'un " - "assistant. Si la réponse est correcte et complète, réponds exactement " - "PARFAIT. Sinon, liste au plus 3 défauts concrets (erreurs, oublis, " - "incohérences), un par ligne." -) - -_REVISE_PROMPT = ( - "Réécris la réponse en corrigeant les défauts listés. Donne UNIQUEMENT la " - "réponse finale corrigée, sans commentaire sur la révision." -) - - -def needs_plan(message: str) -> bool: - """Une demande assez longue/composée mérite un plan explicite.""" - if len(message) < 120: - return False - connectors = len(re.findall( - r"\b(puis|ensuite|après|avec|ainsi que|et aussi|également)\b", - message, re.I, - )) - return len(message) > 240 or connectors >= 2 - - -async def _ask( - model: str, - system: str, - user: str, - *, - num_predict: int, - options: dict | None = None, - keep_alive: str | None = None, -) -> str: - """Appel court au modèle. - - ``options`` doit contenir les options runner (num_ctx, num_batch…) du chat - principal : un appel avec des options divergentes force Ollama à recharger - le modèle en plein message. - """ - messages = [{"role": "system", "content": system}, - {"role": "user", "content": user}] - opts = {**(options or {}), "temperature": 0.2, "num_predict": num_predict} - # think=False : un appel utilitaire (plan, critique) ne doit jamais - # « réfléchir » — sur un modèle thinking, la pensée dévore le budget et - # multiplie la latence. Repli sans le paramètre si le modèle le refuse. - think: bool | None = False - while True: - text = "" - try: - async for chunk in ollama.chat( - model, messages, options=opts, think=think, - keep_alive=keep_alive, stream=True, - ): - text += chunk.get("message", {}).get("content", "") - if chunk.get("done"): - break - return text.strip() - except OllamaError as exc: - if think is False and "think" in str(exc).lower(): - think = None - continue - raise - - -async def make_plan( - model: str, - message: str, - *, - code: bool = False, - options: dict | None = None, - keep_alive: str | None = None, -) -> list[str]: - """Renvoie la liste des étapes (vide si échec — jamais bloquant). - - ``code=True`` bascule sur un plan d'ARCHITECTURE/implémentation concret - (fichiers, structure, fonctionnalités) plutôt qu'une liste d'objectifs. - """ - try: - raw = await _ask( - model, _PLAN_CODE_PROMPT if code else _PLAN_PROMPT, - message[:1200], num_predict=260, - options=options, keep_alive=keep_alive, - ) - except (httpx.HTTPError, OSError) as exc: - logger.warning("Plan impossible : %s", exc) - return [] - steps = [] - for line in raw.splitlines(): - line = line.strip() - m = re.match(r"^\d+[.)]\s*(.+)$", line) - if m: - steps.append(m.group(1).strip()) - return steps[:5] if len(steps) >= 2 else [] - - -async def self_review( - model: str, - request: str, - answer: str, - *, - options: dict | None = None, - keep_alive: str | None = None, -) -> str | None: - """Critique puis révise la réponse. None si rien à corriger / échec.""" - if len(answer) < 80: - return None - try: - critique = await _ask( - model, - _CRITIQUE_PROMPT, - f"Demande :\n{request[:800]}\n\nRéponse :\n{answer[:2500]}", - num_predict=180, - options=options, keep_alive=keep_alive, - ) - if not critique or "PARFAIT" in critique.upper()[:40]: - return None - - revised = await _ask( - model, - _REVISE_PROMPT, - f"Demande :\n{request[:800]}\n\nRéponse initiale :\n{answer[:2500]}" - f"\n\nDéfauts :\n{critique[:600]}", - num_predict=1500, - options=options, keep_alive=keep_alive, - ) - # Garde-fou : une révision vide ou minuscule ne remplace rien. - return revised if len(revised) > len(answer) // 3 else None - except (httpx.HTTPError, OSError) as exc: - logger.warning("Auto-critique impossible : %s", exc) - return None diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index fc416ab..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Point d'entrée FastAPI de Loki. - -Sert l'API (/api/*) et, en production, le frontend React compilé (static/). -""" -from __future__ import annotations - -import os -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles - -from . import agent_config, coder, db, rag -from .config import settings -from .ollama_client import ollama -from .routes import ( - benchmark, chat, config, files, git, mcp, models, projects, sessions, - shell, system, -) - - -async def _warm_default_model() -> None: - """Précharge le modèle par défaut en VRAM au démarrage (best-effort).""" - import asyncio - import logging - - await asyncio.sleep(2) # laisse le service démarrer - try: - cfg = agent_config.get_config(settings.default_model) - models.start_model_warm( - settings.default_model, cfg.get("keep_alive", "30m") - ) - logging.getLogger(__name__).info( - "Préchargement du modèle %s lancé", settings.default_model - ) - except Exception as exc: # best-effort - logging.getLogger(__name__).info( - "Préchargement au démarrage ignoré : %s", exc - ) - - -@asynccontextmanager -async def lifespan(_: FastAPI): - import asyncio - - db.init_db() - rag.init_table() - # Workspace en dépôt git : requis pour les commits du moteur code (Aider). - coder.ensure_git(settings.workspace_dir) - # Préchargement du modèle par défaut, sans bloquer le démarrage. - asyncio.create_task(_warm_default_model()) - yield - # Ferme le pool HTTP partagé vers Ollama et les sessions MCP. - await ollama.aclose() - from .mcp_client import manager as mcp_manager - await mcp_manager.aclose() - - -app = FastAPI( - title="Loki", description="Agent IA local sur Ollama", lifespan=lifespan -) - -# En dev, le front tourne sur Vite (5173). On autorise le CORS large ; -# en prod le front est servi par le même origin, donc sans impact. -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - -app.include_router(models.router) -app.include_router(sessions.router) -app.include_router(chat.router) -app.include_router(files.router) -app.include_router(config.router) -app.include_router(shell.router) -app.include_router(system.router) -app.include_router(benchmark.router) -app.include_router(git.router) -app.include_router(mcp.router) -app.include_router(projects.router) - - -@app.get("/api/health") -async def health() -> dict: - return {"status": "ok", "service": "loki", "version": settings.loki_version} - - -@app.get("/api/version") -async def version() -> dict: - """Marqueur de build — pour vérifier que l'image déployée est à jour.""" - return {"version": settings.loki_version} - - -# ── Service du frontend compilé (présent uniquement en image Docker) ───── -_STATIC_DIR = os.path.join(os.path.dirname(__file__), "..", "static") -if os.path.isdir(_STATIC_DIR): - app.mount( - "/assets", - StaticFiles(directory=os.path.join(_STATIC_DIR, "assets")), - name="assets", - ) - - @app.get("/{full_path:path}") - async def spa_fallback(full_path: str): - """Fichier statique racine s'il existe (favicon…), sinon index.html. - - Sans ce test, /favicon.svg recevait index.html : aucun favicon ne - s'affichait en production. - """ - static_root = os.path.abspath(_STATIC_DIR) - candidate = os.path.abspath(os.path.join(static_root, full_path)) - if ( - full_path - and candidate.startswith(static_root + os.sep) - and os.path.isfile(candidate) - ): - return FileResponse(candidate) - return FileResponse(os.path.join(static_root, "index.html")) diff --git a/backend/app/mcp_client.py b/backend/app/mcp_client.py deleted file mode 100644 index 15b96bf..0000000 --- a/backend/app/mcp_client.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Client MCP : catalogue préconfiguré + sessions vers les serveurs activés. - -Un serveur désactivé n'est jamais démarré et n'expose aucun outil au modèle -(chaque outil injecté coûte du contexte). Connexion lazy au premier message, -session réutilisée ensuite. Toute panne est non bloquante pour le chat. -""" -from __future__ import annotations - -import asyncio -import logging -import re -import shlex -from contextlib import AsyncExitStack - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -from . import db - -logger = logging.getLogger(__name__) - -MCP_KEY = "mcp" - -# Catalogue embarqué. command=None => serveur "custom" (commande utilisateur). -CATALOG: dict[str, dict] = { - "playwright": { - "label": "Playwright (navigateur)", - "description": "Pilote un vrai navigateur : naviguer, cliquer, lire " - "la console, captures. L'agent teste réellement ses pages.", - "command": ["npx", "@playwright/mcp@latest", "--headless"], - "url_param": False, - "env_params": [], - # Limite le nombre d'outils injectés dans le prompt. - "expose": [ - "browser_navigate", "browser_click", "browser_type", - "browser_snapshot", "browser_console_messages", - "browser_take_screenshot", - ], - }, - "context7": { - "label": "Context7 (documentation)", - "description": "Documentation à jour de n'importe quelle librairie ou " - "framework (React, FastAPI, Tailwind…).", - "command": ["npx", "-y", "@upstash/context7-mcp"], - "url_param": False, - "env_params": [], - "expose": None, - }, - "fetch": { - "label": "Fetch (lecture web)", - "description": "Lit proprement n'importe quelle URL (markdown épuré).", - "command": ["python", "-m", "mcp_server_fetch"], - "url_param": False, - "env_params": [], - "expose": None, - }, - "searxng": { - "label": "SearxNG (recherche web)", - "description": "Vraie recherche web via une instance SearxNG " - "(renseigner SEARXNG_URL).", - "command": ["npx", "-y", "mcp-searxng"], - "url_param": False, - "env_params": ["SEARXNG_URL"], - "expose": None, - }, - "custom": { - "label": "Personnalisé", - "description": "N'importe quel serveur MCP : colle une commande " - "(stdio) ou une URL (streamable HTTP).", - "command": None, - "url_param": True, - "env_params": [], - "expose": None, - }, -} - - -def get_mcp_state() -> dict: - """État activé/params de chaque serveur du catalogue (défaut : désactivé).""" - stored = db.get_config_value(MCP_KEY) or {} - return { - sid: { - "enabled": bool(stored.get(sid, {}).get("enabled", False)), - "params": dict(stored.get(sid, {}).get("params", {})), - } - for sid in CATALOG - } - - -def set_mcp_state(sid: str, *, enabled: bool, params: dict) -> dict: - if sid not in CATALOG: - raise KeyError(sid) - state = get_mcp_state() - state[sid] = {"enabled": enabled, "params": dict(params)} - db.set_config_value(MCP_KEY, state) - return state - - -_CALL_TIMEOUT = 30.0 -# Généreux : le premier lancement d'un serveur npx (Playwright, Context7…) -# télécharge le paquet — souvent bien plus de 20 s. Les démarrages suivants -# sont instantanés (cache npm). -_CONNECT_TIMEOUT = 90.0 -_MAX_RESULT_CHARS = 8000 - - -def _safe_tool_name(name: str) -> str: - """Nom d'outil compatible function-calling (lettres/chiffres/underscore). - - Les noms MCP peuvent contenir des tirets (« resolve-library-id ») que les - grammaires de tool-calling et les modèles mélangent avec des underscores — - source de « Tool not found ». On expose une version assainie et on garde - la correspondance vers le vrai nom. - """ - return re.sub(r"[^a-zA-Z0-9_]", "_", name) - - -class _ServerConn: - """Session vivante vers un serveur MCP (process stdio + handshake).""" - - def __init__(self, sid: str) -> None: - self.sid = sid - self.stack = AsyncExitStack() - self.session: ClientSession | None = None - self.tools: list[dict] = [] # définitions format Ollama - # nom exposé au modèle -> vrai nom d'outil MCP - self.name_map: dict[str, str] = {} - - async def start(self) -> None: - entry = CATALOG[self.sid] - params = get_mcp_state()[self.sid]["params"] - spec = params.get("command", "").strip() - if entry["command"] is None and spec.startswith(("http://", "https://")): - # Serveur "custom" en streamable HTTP (URL collée par l'utilisateur). - from mcp.client.streamable_http import streamablehttp_client - read, write, _ = await self.stack.enter_async_context( - streamablehttp_client(spec) - ) - else: - if entry["command"] is None: - # Serveur "custom" : commande stdio saisie par l'utilisateur. - command = shlex.split(spec) - if not command: - raise ValueError("commande du serveur personnalisé vide") - else: - command = list(entry["command"]) - # Paramètres obligatoires (ex. SEARXNG_URL) : refus clair AVANT le - # lancement, plutôt qu'un échec cryptique à chaque appel d'outil. - missing = [k for k in entry["env_params"] if not params.get(k)] - if missing: - raise ValueError( - f"{', '.join(missing)} requis — renseigne ce champ dans la " - "carte du serveur (Configuration → Serveurs MCP)" - ) - env = {k: params[k] for k in entry["env_params"] if params.get(k)} - server = StdioServerParameters( - command=command[0], args=command[1:], env=env or None - ) - read, write = await self.stack.enter_async_context( - stdio_client(server) - ) - self.session = await self.stack.enter_async_context( - ClientSession(read, write) - ) - await asyncio.wait_for(self.session.initialize(), _CONNECT_TIMEOUT) - listed = await asyncio.wait_for(self.session.list_tools(), _CONNECT_TIMEOUT) - expose = entry.get("expose") - self.tools = [] - self.name_map = {} - for t in listed.tools: - if expose is not None and t.name not in expose: - continue - exposed = f"mcp_{self.sid}_{_safe_tool_name(t.name)}" - self.name_map[exposed] = t.name - self.tools.append({ - "type": "function", - "function": { - "name": exposed, - "description": (t.description or t.name)[:400], - "parameters": t.inputSchema - or {"type": "object", "properties": {}}, - }, - }) - - async def close(self) -> None: - try: - await self.stack.aclose() - except Exception: # process déjà mort : sans importance - pass - - -class McpManager: - """Sessions MCP lazy + dispatch d'appels d'outils, jamais bloquant.""" - - def __init__(self) -> None: - self._conns: dict[str, _ServerConn] = {} - self._errors: dict[str, str] = {} - self._notices: list[str] = [] - self._lock = asyncio.Lock() - - async def _ensure(self, sid: str) -> _ServerConn | None: - if sid in self._conns: - return self._conns[sid] - try: - conn = _ServerConn(sid) - await conn.start() - except Exception as exc: - if isinstance(exc, (asyncio.TimeoutError, TimeoutError)): - msg = ( - "délai de démarrage dépassé — premier lancement d'un " - "serveur npx (téléchargement) ou paquet absent de l'image. " - "Réessaie au prochain message." - ) - else: - msg = str(exc)[:300] or exc.__class__.__name__ - logger.warning("Serveur MCP %s indisponible : %s", sid, msg) - self._errors[sid] = msg - self._notices.append( - f"Serveur MCP « {CATALOG[sid]['label']} » indisponible : {msg}" - ) - return None - self._errors.pop(sid, None) - self._conns[sid] = conn - return conn - - async def tool_definitions(self) -> list[dict]: - """Outils des serveurs activés (connexion lazy, pannes ignorées).""" - defs: list[dict] = [] - async with self._lock: - state = get_mcp_state() - # Ferme les serveurs désactivés entre-temps. - for sid in [s for s in self._conns if not state[s]["enabled"]]: - await self._conns.pop(sid).close() - for sid, st in state.items(): - if not st["enabled"]: - continue - conn = await self._ensure(sid) - if conn: - defs.extend(conn.tools) - return defs - - def _resolve(self, prefixed_name: str) -> tuple[str, str] | None: - """(sid, vrai nom d'outil) depuis le nom exposé au modèle. - - Résolution par table de correspondance, avec tolérance : les modèles - confondent parfois tirets et underscores dans les noms d'outils. - """ - wanted = _safe_tool_name(prefixed_name) - for sid, conn in self._conns.items(): - for exposed, real in conn.name_map.items(): - if exposed == prefixed_name or exposed == wanted: - return sid, real - # Repli : découpage mcp__ (serveur pas encore connecté). - try: - _, sid, tool = prefixed_name.split("_", 2) - return sid, tool - except ValueError: - return None - - async def call_tool(self, prefixed_name: str, args: dict) -> dict: - resolved = self._resolve(prefixed_name) - if resolved is None: - return {"ok": False, "content": "", "summary": "nom d'outil invalide"} - sid, tool = resolved - async with self._lock: - conn = self._conns.get(sid) or await self._ensure(sid) - if conn is None or conn.session is None: - return {"ok": False, "content": "", - "summary": f"serveur MCP {sid} indisponible"} - # Serveur (re)connecté après le repli : re-résout via sa table. - if conn.name_map: - tool = conn.name_map.get(prefixed_name) or conn.name_map.get( - _safe_tool_name(prefixed_name), tool - ) - try: - result = await asyncio.wait_for( - conn.session.call_tool(tool, args or {}), _CALL_TIMEOUT - ) - except Exception as exc: - # Session probablement morte : on la ferme, retry au prochain tour. - async with self._lock: - dead = self._conns.pop(sid, None) - if dead: - await dead.close() - return {"ok": False, "content": "", - "summary": f"échec MCP : {str(exc)[:200]}"} - parts = [ - c.text for c in result.content - if getattr(c, "type", "") == "text" and getattr(c, "text", "") - ] - content = "\n".join(parts)[:_MAX_RESULT_CHARS] - ok = not bool(getattr(result, "isError", False)) - return { - "ok": ok, - "content": content, - "summary": (content.splitlines()[0][:120] if content else "terminé") - if ok else (content[:120] or "erreur outil MCP"), - } - - def statuses(self) -> dict[str, dict]: - state = get_mcp_state() - out = {} - for sid, st in state.items(): - if sid in self._conns: - s = "connected" - elif sid in self._errors: - s = "error" - else: - s = "inactive" - out[sid] = { - "state": s if st["enabled"] else "inactive", - "error": self._errors.get(sid), - "tools": len(self._conns[sid].tools) if sid in self._conns else 0, - } - return out - - def notices(self) -> list[str]: - out, self._notices = self._notices, [] - return out - - async def test_server(self, sid: str) -> dict: - """Connexion d'essai indépendante (n'altère pas les sessions).""" - conn = _ServerConn(sid) - try: - await conn.start() - return {"ok": True, - "tools": [d["function"]["name"] for d in conn.tools], - "error": None} - except Exception as exc: - return {"ok": False, "tools": [], - "error": str(exc)[:300] or exc.__class__.__name__} - finally: - await conn.close() - - async def aclose(self) -> None: - for conn in list(self._conns.values()): - await conn.close() - self._conns.clear() - - -manager = McpManager() diff --git a/backend/app/memory.py b/backend/app/memory.py deleted file mode 100644 index 8daa08c..0000000 --- a/backend/app/memory.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Mémoire de conversation compressée — le vrai levier des petits modèles. - -Un petit modèle se noie dans un long historique : il oublie la consigne, part -en boucle, et un grand num_ctx le fait déborder du GPU. On garde donc : - [invite système] + [résumé compact des anciens tours] + [N derniers messages] - -Le résumé est régénéré en arrière-plan (après la réponse, sans latence pour -l'utilisateur) dès que l'historique dépasse le seuil. -""" -from __future__ import annotations - -import logging - -import httpx - -from . import db -from .ollama_client import OllamaError, ollama - -logger = logging.getLogger(__name__) - -# Nombre de messages récents passés tels quels au modèle. -KEEP_RECENT = 10 -# Au-delà de ce total, les anciens tours sont compressés dans le résumé. -SUMMARIZE_AFTER = KEEP_RECENT + 6 - -_SUMMARY_PROMPT = ( - "Résume la conversation ci-dessous en français, en 10 lignes maximum. " - "Conserve impérativement : l'objectif de l'utilisateur, les décisions " - "prises, les fichiers créés/modifiés et leur rôle, et les points encore " - "ouverts. Réponds UNIQUEMENT par le résumé." -) - - -def build_convo(sid: str, system_prompt: str) -> list[dict]: - """Construit le contexte : système + résumé éventuel + messages récents. - - Le résumé est fusionné DANS l'invite système plutôt qu'ajouté comme second - message système : de nombreux templates (Gemma, Mistral…) rejettent tout - message système qui n'est pas le premier de la liste. - """ - session = db.get_session(sid) or {} - summary = (session.get("summary") or "").strip() - messages = db.list_messages_for_model(sid) - - if summary and len(messages) > KEEP_RECENT: - system_prompt = ( - f"{system_prompt}\n\nRésumé des échanges précédents :\n{summary}" - ) - messages = messages[-KEEP_RECENT:] - - return [{"role": "system", "content": system_prompt}, *messages] - - -async def maybe_summarize( - sid: str, - model: str, - *, - options: dict | None = None, - keep_alive: str | None = None, -) -> None: - """Compresse les anciens tours dans le résumé (tâche d'arrière-plan). - - ``options`` doit reprendre les options runner du chat (num_ctx…) pour ne - pas déclencher un rechargement du modèle après chaque réponse. - """ - try: - messages = db.list_messages_for_model(sid) - if len(messages) <= SUMMARIZE_AFTER: - return - - session = db.get_session(sid) or {} - previous = (session.get("summary") or "").strip() - old = messages[:-KEEP_RECENT] - - transcript = "\n".join( - f"[{m['role']}] {m['content'][:600]}" for m in old - )[-8000:] - if previous: - transcript = f"[résumé existant] {previous}\n{transcript}" - - messages = [ - {"role": "system", "content": _SUMMARY_PROMPT}, - {"role": "user", "content": transcript}, - ] - opts = {**(options or {}), "temperature": 0.2, "num_predict": 350} - # think=False : le résumé d'arrière-plan ne doit pas « réfléchir » - # (latence ×3 sur un modèle thinking). Repli si paramètre refusé. - think: bool | None = False - while True: - text = "" - try: - async for chunk in ollama.chat( - model, messages, options=opts, think=think, - keep_alive=keep_alive, stream=True, - ): - text += chunk.get("message", {}).get("content", "") - if chunk.get("done"): - break - break - except OllamaError as exc: - if think is False and "think" in str(exc).lower(): - think = None - continue - raise - - if text.strip(): - db.set_session_summary(sid, text.strip()) - except (httpx.HTTPError, OSError) as exc: - # Best-effort : un échec de résumé ne doit jamais gêner le chat. - logger.warning("Résumé de session %s impossible : %s", sid, exc) diff --git a/backend/app/memory_notes.py b/backend/app/memory_notes.py deleted file mode 100644 index f836dd1..0000000 --- a/backend/app/memory_notes.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Mémoire en notes Markdown : l'agent écrit lui-même ce qu'il veut retenir. - -Repris d'AJEAN (github.com/nathaninline/jean) : des notes lisibles rangées -dans ``$DATA_DIR/MEMORY/``, que le modèle consulte et enregistre EXPLICITEMENT. - -Face au RAG vectoriel, trois avantages décisifs : - - aucun modèle d'embedding requis (marche dès l'installation) ; - - le contenu est inspectable et modifiable à la main ; - - rien n'est mémorisé « à l'insu » : pas de vieille demande sans rapport qui - ressurgit au milieu d'une nouvelle discussion. - -Trois modes (config ``memory_mode``) : - off — rien du tout ; - ondemand — les outils memory_search / memory_save sont proposés au modèle ; - always — idem, plus l'injection automatique des notes pertinentes. -""" -from __future__ import annotations - -import os -import re -import time -import unicodedata - -from .config import settings - -MODES = ("off", "ondemand", "always") - -# Bornes : une note reste une note, pas une archive. -_MAX_NOTE_CHARS = 4000 -_MAX_NOTES = 200 -_EXCERPT_CHARS = 400 - - -def _dir() -> str: - path = os.path.join(os.path.abspath(settings.data_dir), "MEMORY") - os.makedirs(path, exist_ok=True) - return path - - -def _slug(title: str) -> str: - """Nom de fichier sûr dérivé du titre (confiné au dossier MEMORY).""" - text = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode() - text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower() - return (text or "note")[:60] - - -def _tokens(text: str) -> set[str]: - """Mots significatifs, sans accents ni casse (>= 3 lettres).""" - text = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode() - return {w for w in re.findall(r"[a-z0-9]{3,}", text.lower())} - - -def save_note(title: str, content: str) -> dict: - """Crée ou remplace une note. Renvoie {ok, summary}.""" - title = (title or "").strip() - content = (content or "").strip() - if not title: - return {"ok": False, "summary": "titre vide"} - if not content: - return {"ok": False, "summary": "contenu vide"} - - content = content[:_MAX_NOTE_CHARS] - path = os.path.join(_dir(), f"{_slug(title)}.md") - existed = os.path.isfile(path) - with open(path, "w", encoding="utf-8") as f: - f.write(f"# {title}\n\n{content}\n") - - _prune() - verb = "mise à jour" if existed else "enregistrée" - return {"ok": True, "summary": f"note {verb} : {title}"} - - -def _prune() -> None: - """Garde les notes les plus récentes (borne dure, jamais de purge totale).""" - files = [os.path.join(_dir(), n) for n in os.listdir(_dir()) if n.endswith(".md")] - if len(files) <= _MAX_NOTES: - return - files.sort(key=lambda p: os.path.getmtime(p), reverse=True) - for path in files[_MAX_NOTES:]: - try: - os.remove(path) - except OSError: - pass - - -def _read_all() -> list[tuple[str, str]]: - """[(titre, corps)] de toutes les notes.""" - out = [] - try: - names = sorted(os.listdir(_dir())) - except OSError: - return out - for name in names: - if not name.endswith(".md"): - continue - try: - with open(os.path.join(_dir(), name), encoding="utf-8", errors="replace") as f: - raw = f.read() - except OSError: - continue - first, _, body = raw.partition("\n") - title = first.lstrip("# ").strip() or name[:-3] - out.append((title, body.strip())) - return out - - -def search_notes(query: str, limit: int = 3) -> list[dict]: - """Notes les plus proches de la requête (recouvrement de mots-clés).""" - wanted = _tokens(query) - if not wanted: - return [] - scored: list[tuple[int, str, str]] = [] - for title, body in _read_all(): - score = len(wanted & _tokens(f"{title} {body}")) - if score: - scored.append((score, title, body)) - scored.sort(key=lambda item: -item[0]) - return [ - {"title": title, "content": body[:_EXCERPT_CHARS]} - for _, title, body in scored[:limit] - ] - - -def list_notes() -> list[dict]: - """Inventaire des notes (pour l'UI / l'inspection).""" - notes = [] - for title, body in _read_all(): - path = os.path.join(_dir(), f"{_slug(title)}.md") - notes.append({ - "title": title, - "chars": len(body), - "updated_at": os.path.getmtime(path) if os.path.isfile(path) else time.time(), - }) - return sorted(notes, key=lambda n: -n["updated_at"]) - - -def delete_note(title: str) -> bool: - path = os.path.join(_dir(), f"{_slug(title)}.md") - if not os.path.isfile(path): - return False - os.remove(path) - return True - - -def recall_block(message: str) -> str | None: - """Bloc de contexte à injecter en mode ``always`` (None si rien de net).""" - hits = search_notes(message, limit=2) - if not hits: - return None - body = "\n\n".join(f"## {h['title']}\n{h['content']}" for h in hits) - return ( - "Notes que tu avais enregistrées et qui semblent liées à cette demande " - "(ignore-les si elles ne s'appliquent pas) :\n" + body - ) diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py deleted file mode 100644 index f7b7135..0000000 --- a/backend/app/ollama_client.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Client HTTP léger pour l'API Ollama. - -On utilise httpx directement (plutôt que le SDK) pour garder le contrôle -total sur le streaming et n'embarquer aucune dépendance superflue. -""" -from __future__ import annotations - -import asyncio -import json -import time -from typing import AsyncIterator - -import httpx - -from .config import settings - - -class OllamaError(RuntimeError): - """Erreur renvoyée par Ollama (statut HTTP ≥ 400 ou champ ``error`` dans le flux). - - Ollama signale certains échecs *au milieu* d'un flux streaming (HTTP 200) - via une ligne JSON ``{"error": "..."}`` — typiquement un débordement mémoire - ou un contexte trop grand. On lève alors cette exception pour que l'appelant - la remonte à l'utilisateur au lieu de l'avaler silencieusement. - """ - - -# Connexion rapide à échouer si Ollama est injoignable, mais lecture sans limite : -# une génération longue (ou un chargement de modèle sur CPU) ne doit pas couper. -_STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0) - -# Un chat déclenche plusieurs appels Ollama (routage, embed, plan, agent…) : -# un pool keep-alive partagé évite un handshake TCP à chaque appel. -_LIMITS = httpx.Limits( - max_connections=20, max_keepalive_connections=10, keepalive_expiry=30.0 -) - - -async def _raise_for_stream_status(resp: httpx.Response) -> None: - """Lève une ``OllamaError`` détaillée si la réponse streaming est en erreur. - - Sur une réponse en flux, ``raise_for_status`` n'inclut pas le corps ; on le - lit explicitement pour exposer le message d'Ollama (modèle absent, etc.). - """ - if resp.status_code < 400: - return - body = await resp.aread() - detail = body.decode(errors="replace").strip() - try: - detail = json.loads(detail).get("error", detail) - except (json.JSONDecodeError, AttributeError): - pass - raise OllamaError(f"Ollama a renvoyé {resp.status_code} : {detail[:500]}") - - -class OllamaClient: - """Enveloppe asynchrone autour de l'API REST d'Ollama.""" - - def __init__(self, host: str | None = None) -> None: - self.host = (host or settings.ollama_host).rstrip("/") - self._client: httpx.AsyncClient | None = None - # Cache court de /api/tags : la liste des modèles installés change - # rarement mais est consultée par plusieurs modules à chaque message. - self._tags_at = 0.0 - self._tags: list[dict] = [] - - def _http(self) -> httpx.AsyncClient: - """Client partagé (pool keep-alive), créé paresseusement. - - Un seul worker uvicorn / une seule boucle : la création lazy est sûre. - Le garde ``is_closed`` recrée le client si un arrêt l'a fermé. - """ - if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient( - timeout=_STREAM_TIMEOUT, follow_redirects=True, limits=_LIMITS - ) - return self._client - - async def aclose(self) -> None: - """Ferme le pool partagé (appelé au shutdown de l'app).""" - if self._client is not None and not self._client.is_closed: - await self._client.aclose() - - async def ping(self) -> dict: - """Vérifie la connexion et renvoie la version d'Ollama.""" - resp = await self._http().get(f"{self.host}/api/version", timeout=5.0) - resp.raise_for_status() - return resp.json() - - async def list_models(self) -> list[dict]: - """Liste les modèles installés localement (/api/tags), sans cache.""" - resp = await self._http().get(f"{self.host}/api/tags", timeout=10.0) - resp.raise_for_status() - models = resp.json().get("models", []) - self._tags, self._tags_at = models, time.monotonic() - return models - - async def list_models_cached(self, ttl: float = 30.0) -> list[dict]: - """Comme ``list_models`` mais avec un cache court partagé. - - Utilisé par les chemins chauds (routage code, résolution embed, route - /api/models) pour ne pas marteler /api/tags à chaque message. - """ - if self._tags and time.monotonic() - self._tags_at < ttl: - return self._tags - return await self.list_models() - - def invalidate_tags_cache(self) -> None: - """Force un rafraîchissement après un pull ou une suppression de modèle.""" - self._tags_at = 0.0 - self._tags = [] - - async def embed( - self, model: str, texts: list[str], keep_alive: str = "30m" - ) -> list[list[float]]: - """Vecteurs d'embedding pour une liste de textes (/api/embed). - - ``keep_alive`` long : le modèle d'embedding est minuscule (<0,5 Go) et - sollicité à chaque message (recall + indexation) — le laisser chargé - évite un aller-retour VRAM permanent avec le modèle de chat. - """ - resp = await self._http().post( - f"{self.host}/api/embed", - json={"model": model, "input": texts, "keep_alive": keep_alive}, - timeout=30.0, - ) - resp.raise_for_status() - return resp.json().get("embeddings", []) - - async def ps(self) -> list[dict]: - """Modèles actuellement chargés et leur répartition VRAM/CPU (/api/ps).""" - resp = await self._http().get(f"{self.host}/api/ps", timeout=5.0) - resp.raise_for_status() - return resp.json().get("models", []) - - async def show(self, name: str) -> dict: - """Métadonnées détaillées d'un modèle (/api/show).""" - resp = await self._http().post( - f"{self.host}/api/show", json={"name": name}, timeout=15.0 - ) - resp.raise_for_status() - return resp.json() - - async def delete_model(self, name: str) -> dict: - """Supprime un modèle installé (/api/delete).""" - resp = await self._http().request( - "DELETE", f"{self.host}/api/delete", json={"model": name}, timeout=30.0 - ) - resp.raise_for_status() - self.invalidate_tags_cache() - return resp.json() if resp.content else {"status": "success"} - - async def pull_model(self, name: str) -> AsyncIterator[dict]: - """Télécharge un modèle en streamant la progression (/api/pull).""" - async with self._http().stream( - "POST", - f"{self.host}/api/pull", - json={"name": name}, - timeout=_STREAM_TIMEOUT, - ) as resp: - await _raise_for_stream_status(resp) - async for line in resp.aiter_lines(): - if not line.strip(): - continue - try: - chunk = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(chunk, dict) and chunk.get("error"): - raise OllamaError(str(chunk["error"])) - yield chunk - self.invalidate_tags_cache() - - async def warm( - self, model: str, keep_alive: str = "30m", options: dict | None = None - ) -> dict: - """Précharge un modèle en VRAM sans générer (/api/generate sans prompt). - - Le paramètre keep_alive fixe la durée de rétention en mémoire. Les - `options` (num_ctx, num_batch, num_gpu…) DOIVENT correspondre à celles du - chat : sinon Ollama chargerait un runner distinct puis en rechargerait un - autre au premier message — un double chargement très coûteux pour un gros - modèle. - """ - payload: dict = {"model": model, "keep_alive": keep_alive, "stream": False} - if options: - payload["options"] = options - # Le chargement se fait désormais en tâche de fond côté API Loki. On lui - # laisse jusqu'à dix minutes pour les gros modèles ou un stockage lent. - timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0) - for attempt in range(2): - resp = await self._http().post( - f"{self.host}/api/generate", json=payload, timeout=timeout - ) - try: - # Inclut le corps JSON d'Ollama dans l'erreur (OOM, runner…), - # contrairement à raise_for_status qui ne montrait que « 500 ». - await _raise_for_stream_status(resp) - except OllamaError: - if resp.status_code >= 500 and attempt == 0: - await asyncio.sleep(2) - continue - raise - return resp.json() - raise OllamaError("préchargement interrompu sans réponse") - - async def chat( - self, - model: str, - messages: list[dict], - *, - tools: list[dict] | None = None, - options: dict | None = None, - think: bool | None = None, - keep_alive: str | None = None, - stream: bool = True, - ) -> AsyncIterator[dict]: - """Conversation avec le modèle, en streaming token par token.""" - payload: dict = {"model": model, "messages": messages, "stream": stream} - if tools: - payload["tools"] = tools - if options: - payload["options"] = options - # think=False désactive le raisonnement des modèles « thinking ». - if think is not None: - payload["think"] = think - # keep_alive : durée de maintien du modèle en VRAM après la réponse. - if keep_alive is not None: - payload["keep_alive"] = keep_alive - - async with self._http().stream( - "POST", f"{self.host}/api/chat", json=payload, timeout=_STREAM_TIMEOUT - ) as resp: - await _raise_for_stream_status(resp) - async for line in resp.aiter_lines(): - if not line.strip(): - continue - try: - chunk = json.loads(line) - except json.JSONDecodeError: - # Ligne partielle / non-JSON : on l'ignore plutôt que de - # faire planter tout le flux. - continue - # Échec en cours de génération (OOM, contexte trop grand…) : - # Ollama l'émet dans le flux avec HTTP 200. On le remonte. - if isinstance(chunk, dict) and chunk.get("error"): - raise OllamaError(str(chunk["error"])) - yield chunk - - -ollama = OllamaClient() diff --git a/backend/app/rag.py b/backend/app/rag.py deleted file mode 100644 index 30f5046..0000000 --- a/backend/app/rag.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Mémoire long-terme (RAG) : l'agent se souvient des anciennes sessions. - -Chaque échange (question + réponse) est vectorisé via /api/embed d'Ollama et -stocké en SQLite. À chaque nouveau message, on recherche les souvenirs les -plus proches (cosinus) dans les AUTRES sessions et on les injecte en contexte. - -Tout est best-effort : sans modèle d'embedding installé, le RAG se désactive -silencieusement (aucun impact sur le chat). -""" -from __future__ import annotations - -import asyncio -import json -import logging -import math -import time -import uuid - -import httpx - -from . import db -from .ollama_client import ollama - -logger = logging.getLogger(__name__) - -# Modèles d'embedding reconnus, par ordre de préférence. -_EMBED_HINTS = ("nomic-embed", "mxbai-embed", "bge-", "snowflake-arctic-embed", - "all-minilm", "embed") - -_TOP_K = 3 -# Seuil de similarité volontairement élevé : une mémoire n'est rappelée que si -# elle est FORTEMENT proche de la demande. Trop bas (0.45), un « jeu d'échecs » -# rappelait une « appli sport » et déroutait le modèle. -_MIN_SCORE = 0.6 -_MAX_MEMORIES = 2000 # au-delà, on élague les plus anciens - -_embed_model_cache: dict = {"value": None, "checked_at": 0.0} - - -def init_table() -> None: - with db._LOCK, db._connect() as conn: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS memories ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - content TEXT NOT NULL, - embedding TEXT NOT NULL, - created_at REAL NOT NULL - ) - """ - ) - - -async def resolve_embed_model(preference: str | None = None) -> str | None: - """Trouve le modèle d'embedding à utiliser (None = RAG indisponible).""" - if preference and preference != "auto": - return preference - - # Cache 60 s pour ne pas marteler /api/tags. - now = time.time() - if now - _embed_model_cache["checked_at"] < 60: - return _embed_model_cache["value"] - - value = None - try: - for m in await ollama.list_models_cached(): - name = (m.get("name") or "").lower() - if any(h in name for h in _EMBED_HINTS): - value = m["name"] - break - except (httpx.HTTPError, OSError): - value = None - - _embed_model_cache.update(value=value, checked_at=now) - return value - - -def _cosine(a: list[float], b: list[float]) -> float: - dot = sum(x * y for x, y in zip(a, b)) - na = math.sqrt(sum(x * x for x in a)) - nb = math.sqrt(sum(x * x for x in b)) - return dot / (na * nb) if na and nb else 0.0 - - -def _score_rows(qvec: list[float], rows: list) -> list[str]: - """Scoring cosinus sur toutes les mémoires — CPU pur, à lancer via to_thread. - - Jusqu'à _MAX_MEMORIES vecteurs : la boucle Python bloquerait l'event loop - (et donc tous les SSE en cours) pendant plusieurs dizaines de ms. - """ - scored: list[tuple[float, str]] = [] - for row in rows: - try: - score = _cosine(qvec, json.loads(row["embedding"])) - except (ValueError, TypeError): - continue - if score >= _MIN_SCORE: - scored.append((score, row["content"])) - scored.sort(reverse=True) - return [c for _, c in scored[:_TOP_K]] - - -async def index_exchange( - sid: str, user_text: str, assistant_text: str, *, embed_model: str | None -) -> None: - """Indexe un échange terminé (tâche d'arrière-plan, best-effort).""" - model = await resolve_embed_model(embed_model) - if not model: - return - content = f"Q: {user_text[:500]}\nR: {assistant_text[:800]}" - try: - vectors = await ollama.embed(model, [content]) - if not vectors: - return - with db._LOCK, db._connect() as conn: - conn.execute( - "INSERT INTO memories (id, session_id, content, embedding, created_at)" - " VALUES (?, ?, ?, ?, ?)", - (uuid.uuid4().hex, sid, content, - json.dumps(vectors[0]), time.time()), - ) - # Élagage des souvenirs les plus anciens. - conn.execute( - "DELETE FROM memories WHERE id IN (" - " SELECT id FROM memories ORDER BY created_at DESC" - f" LIMIT -1 OFFSET {_MAX_MEMORIES})" - ) - except (httpx.HTTPError, OSError) as exc: - logger.warning("Indexation RAG impossible : %s", exc) - - -async def recall( - sid: str, query: str, *, embed_model: str | None -) -> list[str]: - """Souvenirs pertinents issus des AUTRES sessions (top-k, score minimal).""" - model = await resolve_embed_model(embed_model) - if not model: - return [] - try: - vectors = await ollama.embed(model, [query[:800]]) - if not vectors: - return [] - qvec = vectors[0] - - with db._LOCK, db._connect() as conn: - rows = conn.execute( - "SELECT content, embedding FROM memories WHERE session_id != ?", - (sid,), - ).fetchall() - - return await asyncio.to_thread(_score_rows, qvec, rows) - except (httpx.HTTPError, OSError) as exc: - logger.warning("Rappel RAG impossible : %s", exc) - return [] diff --git a/backend/app/router.py b/backend/app/router.py deleted file mode 100644 index 9968dda..0000000 --- a/backend/app/router.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Routeur automatique : moteur code (Aider) ou boucle agent classique ? - -Invisible pour l'utilisateur : chaque message est classé par une heuristique -lexicale instantanée. Les cas ambigus partent vers la boucle agent, qui garde -l'outil `code_task` en secours — plus d'appel LLM bloquant avant le premier -token (l'ancien micro-classifieur coûtait un aller-retour modèle complet et -chargeait un runner divergent). -""" -from __future__ import annotations - -import re - -# Vocabulaire fortement lié au code / au développement. -_STRONG = re.compile( - r"\b(code|coder?|script|fonction|classe|refactor|bug|d[ée]bug|html|css|" - r"javascript|js|python|typescript|react|api|composant|page|site|landing|" - r"formulaire|d[ée]veloppe|impl[ée]mente|programme|widget|frontend|backend)\b", - re.I, -) -# Extensions de fichiers mentionnées explicitement. -_FILE_EXT = re.compile( - r"\.(py|js|ts|tsx|jsx|html?|css|json|md|sh|sql|ya?ml|go|rs|java|php|vue)\b", re.I -) -# Verbes d'action de création/modification. -_ACTION = re.compile( - r"\b(cr[ée]e[rs]?|modifie[rs]?|corrige[rs]?|ajoute[rs]?|[ée]cri[st]|refai[st]|" - r"am[ée]liore[rs]?|fixe?|update|change[rs]?|construis|g[ée]n[èe]re[rs]?)\b", - re.I, -) - - -def score_code_task(message: str) -> int: - """Score heuristique : >= 3 -> code, <= 2 -> agent.""" - score = 0 - if _STRONG.search(message): - score += 2 - if _FILE_EXT.search(message): - score += 2 - if _ACTION.search(message): - score += 1 - if "```" in message: - score += 2 - return score - - -def is_code_task(message: str) -> bool: - """Décision : heuristique pure, aucune requête modèle.""" - return score_code_task(message) >= 3 - - -# Formulations de « suite de travail » : courtes, sans vocabulaire code -# explicite, mais qui prolongent clairement la tâche en cours. -_FOLLOWUP = re.compile( - r"\b(continue[rs]?|reprend[s]?|poursui[st]|termine[rs]?|finis|" - r"rajoute[rs]?|enl[èe]ve[rs]?|retire[rs]?|supprime[rs]?|d[ée]place[rs]?|" - r"agrandi[st]|r[ée]dui[st]|remet[s]?|remplace[rs]?|inverse[rs]?|" - r"plut[ôo]t|aussi|encore|pareil|m[êe]me chose|comme avant)\b", - re.I, -) - - -def is_code_followup(message: str) -> bool: - """Reprise probable d'un travail de code en cours. - - À n'utiliser que si le tour précédent de la session était du code : un - « ajoute un bouton rouge » isolé n'est pas du code, mais après un - code_task, si. - """ - return bool(_FOLLOWUP.search(message)) or score_code_task(message) >= 1 diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/routes/benchmark.py b/backend/app/routes/benchmark.py deleted file mode 100644 index bd00614..0000000 --- a/backend/app/routes/benchmark.py +++ /dev/null @@ -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"}, - ) diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py deleted file mode 100644 index 0233b99..0000000 --- a/backend/app/routes/chat.py +++ /dev/null @@ -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", - }, - ) diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py deleted file mode 100644 index 999f94d..0000000 --- a/backend/app/routes/config.py +++ /dev/null @@ -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)} diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py deleted file mode 100644 index 467833b..0000000 --- a/backend/app/routes/files.py +++ /dev/null @@ -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/`` (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} diff --git a/backend/app/routes/git.py b/backend/app/routes/git.py deleted file mode 100644 index 80feba2..0000000 --- a/backend/app/routes/git.py +++ /dev/null @@ -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é"} diff --git a/backend/app/routes/mcp.py b/backend/app/routes/mcp.py deleted file mode 100644 index 5681766..0000000 --- a/backend/app/routes/mcp.py +++ /dev/null @@ -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) diff --git a/backend/app/routes/models.py b/backend/app/routes/models.py deleted file mode 100644 index c9f0ce4..0000000 --- a/backend/app/routes/models.py +++ /dev/null @@ -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") diff --git a/backend/app/routes/projects.py b/backend/app/routes/projects.py deleted file mode 100644 index aae47e5..0000000 --- a/backend/app/routes/projects.py +++ /dev/null @@ -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} diff --git a/backend/app/routes/sessions.py b/backend/app/routes/sessions.py deleted file mode 100644 index 99fa1f3..0000000 --- a/backend/app/routes/sessions.py +++ /dev/null @@ -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} diff --git a/backend/app/routes/shell.py b/backend/app/routes/shell.py deleted file mode 100644 index 8aeb902..0000000 --- a/backend/app/routes/shell.py +++ /dev/null @@ -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"], - } diff --git a/backend/app/routes/system.py b/backend/app/routes/system.py deleted file mode 100644 index e932b75..0000000 --- a/backend/app/routes/system.py +++ /dev/null @@ -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." - ), - } diff --git a/backend/app/skills.py b/backend/app/skills.py deleted file mode 100644 index 4867bf3..0000000 --- a/backend/app/skills.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Skills : méthodes expertes injectées automatiquement selon la tâche. - -Sélection lexicale instantanée (aucun appel LLM) : au plus UNE skill par -message, injectée en message système pour ce tour uniquement. -""" -from __future__ import annotations - -import os -import re - -_SKILLS_DIR = os.path.join(os.path.dirname(__file__), "..", "skills") - -# Ponytail (https://github.com/DietrichGebert/ponytail) : philosophie de code -# « paresseux » (anti sur-ingénierie). Adaptée en méthode transverse injectée -# pour toute tâche de code — indépendante du sélecteur de skill (mono-skill). -PONYTAIL_TITLE = "Ponytail · code minimal" -PONYTAIL_GUIDANCE = ( - "Méthode Ponytail — code minimal, anti sur-ingénierie. Avant d'écrire du " - "code, descends l'échelle de décision et arrête-toi au premier échelon qui " - "suffit :\n" - "1. NE PAS coder ce qui n'est pas explicitement demandé (YAGNI) ;\n" - "2. RÉUTILISER l'existant (fichiers/fonctions déjà présents) ;\n" - "3. UTILISER les fonctions natives du langage / du navigateur ;\n" - "4. en DERNIER recours seulement, écrire le minimum de code nécessaire.\n" - "Livre la solution la plus simple qui fonctionne : aucune dépendance ni " - "bibliothèque à installer, aucune abstraction prématurée, aucune " - "fonctionnalité en plus non demandée (pas de « moteur IA », d'options ou de " - "configuration superflues). Préfère un seul fichier clair à une " - "architecture élaborée." -) - -# Seuil : nombre minimal de mots-clés distincts trouvés dans le message. -_MIN_HITS = 2 - - -def _load() -> dict[str, dict]: - out: dict[str, dict] = {} - if not os.path.isdir(_SKILLS_DIR): - return out - for fname in sorted(os.listdir(_SKILLS_DIR)): - if not fname.endswith(".md"): - continue - with open(os.path.join(_SKILLS_DIR, fname), encoding="utf-8") as f: - raw = f.read() - m = re.match(r"^---\n(.*?)\n---\n(.*)$", raw, re.S) - if not m: - continue - meta: dict[str, str] = {} - for line in m.group(1).splitlines(): - if ":" in line: - key, _, value = line.partition(":") - meta[key.strip()] = value.strip() - keywords = [ - k.strip().lower() - for k in meta.get("keywords", "").split(",") - if k.strip() - ] - out[meta.get("name", fname[:-3])] = { - "name": meta.get("name", fname[:-3]), - "title": meta.get("title", fname[:-3]), - "keywords": keywords, - "body": m.group(2).strip(), - } - return out - - -_WEB_RE = re.compile( - r"\b(html|css|javascript|js|page|site|web|appli|application|interface|" - r"bouton|formulaire|canvas|animation|jeu|game|échiquier|echiquier|" - r"dashboard|landing|maquette|ui|front)\b", - re.I, -) - - -def is_web_task(message: str) -> bool: - """La demande porte-t-elle sur une page / appli web ?""" - return bool(_WEB_RE.search(message or "")) - - -# Contraintes « appli web » : le sandbox de prévisualisation est HORS-LIGNE et -# isolé (pas de CDN, pas de WASM téléchargé). Sans ces règles, les modèles -# promettent des libs (Stockfish, React…) qui ne se chargent jamais, éclatent -# le code en plusieurs fichiers incohérents, puis déclarent « ça marche ». -WEBAPP_GUIDANCE = ( - "Contraintes STRICTES pour une page/appli web :\n" - "1. UN SEUL fichier index.html AUTONOME : tout le CSS dans