mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 09:47:20 +02:00
Un modèle dont les couches partent en RAM tombe à quelques jetons par seconde. Loki subissait la lenteur sans jamais la nommer : l'utilisateur ne pouvait pas distinguer un blocage de l'application d'un modèle placé sur le CPU par Ollama. Au début de chaque réponse, si /api/ps indique que le modèle n'est pas entièrement en VRAM, une notice donne le pourcentage réellement en VRAM et le volume resté en RAM. Le placement appartient à Ollama — Loki le constate, il ne le corrige pas. Silencieux quand tout est sur GPU, quand le modèle n'est pas encore chargé, ou quand Ollama ne répond pas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
747 lines
29 KiB
Python
747 lines
29 KiB
Python
"""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",
|
||
},
|
||
)
|