mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 12:46:56 +02:00
UI : reskin complet vers un theme neo-brutaliste clair (bordures noires 3px, ombres dures, accent orange, polices Press Start 2P + DM Mono). - tailwind.config / index.css / index.html : palette, polices, ombres, scrollbars - TopBar, ActivityBar, LeftPanel, ChatPanel, PreviewPanel, SettingsView, ToolCard, ModelSelector, Icon : restyle complet Chat : panneau « Raisonnement » repliable et redimensionnable affichant le thinking de l'agent en direct (diffuse depuis le backend, persiste dans meta). Robustesse Ollama : les echecs de generation de parseur d'outils (« Unable to generate parser for this template ») declenchent desormais le repli automatique en conversation simple sans outils, au lieu d'un 400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
5.1 KiB
Python
160 lines
5.1 KiB
Python
"""Route de conversation agentique : boucle d'outils + streaming SSE.
|
|
|
|
Flux :
|
|
1. on enregistre le message utilisateur ;
|
|
2. on lance la boucle agentique (run_agent) sur l'historique de la session ;
|
|
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
|
|
from contextlib import suppress
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from .. import agent_config, db
|
|
from ..agent import run_agent
|
|
from ..config import settings
|
|
|
|
router = APIRouter(prefix="/api", tags=["chat"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
session_id: str
|
|
content: str
|
|
model: str | None = None
|
|
|
|
|
|
def _sse(event: str, data: dict) -> str:
|
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
|
|
|
|
@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 = agent_config.get_config(model)
|
|
|
|
# Premier message : titre la session avec un extrait.
|
|
if not db.list_messages(req.session_id):
|
|
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)
|
|
|
|
convo = [{"role": "system", "content": cfg["system_prompt"]}]
|
|
convo += db.list_messages_for_model(req.session_id)
|
|
|
|
async def event_stream():
|
|
yield _sse("start", {"model": model})
|
|
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),
|
|
confirm_shell=cfg.get("confirm_shell", True),
|
|
):
|
|
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())
|
|
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"})
|
|
continue
|
|
|
|
if ev is None:
|
|
break
|
|
|
|
etype = ev.pop("type")
|
|
if etype in (
|
|
"token",
|
|
"thinking",
|
|
"status",
|
|
"notice",
|
|
"tool_call",
|
|
"tool_result",
|
|
"tool_confirm",
|
|
):
|
|
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
|
|
|
|
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
|
|
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,
|
|
},
|
|
)
|
|
|
|
return StreamingResponse(
|
|
event_stream(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache, no-transform",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|