diff --git a/backend/app/agent.py b/backend/app/agent.py index 56e127c..8eaa8eb 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -24,6 +24,12 @@ from .tools import TOOL_DEFINITIONS, ToolError, run_tool MAX_ITERATIONS = 6 +def _tools_not_supported(exc: OllamaError) -> bool: + """Détecte l'erreur Ollama renvoyée par un modèle sans function calling.""" + message = str(exc).lower() + return "does not support tools" in message or "does not support tool" in message + + def _parse_args(raw) -> dict: if isinstance(raw, dict): return raw @@ -54,26 +60,62 @@ async def run_agent( tools = None collected: list[dict] = [] text_parts: list[str] = [] + active_tools = tools + tool_fallback_used = False try: for _ in range(MAX_ITERATIONS): content_buf = "" + thinking_buf = "" + thinking_status_sent = False tool_calls: list[dict] = [] - async for chunk in ollama.chat( - model, convo, tools=tools, options=options, stream=True - ): - msg = chunk.get("message", {}) - token = msg.get("content", "") - if token: - content_buf += token - yield {"type": "token", "content": token} - if msg.get("tool_calls"): - tool_calls.extend(msg["tool_calls"]) - if chunk.get("done"): + # 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=options, stream=True + ): + msg = chunk.get("message", {}) + token = msg.get("content", "") + thinking = msg.get("thinking", "") + if thinking: + thinking_buf += thinking + if not thinking_status_sent: + thinking_status_sent = True + yield {"type": "status", "message": "Réflexion…"} + if token: + content_buf += token + yield {"type": "token", "content": token} + if msg.get("tool_calls"): + tool_calls.extend(msg["tool_calls"]) + if chunk.get("done"): + break break + except OllamaError as exc: + 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 + raise assistant_turn: dict = {"role": "assistant", "content": content_buf} + if thinking_buf: + assistant_turn["thinking"] = thinking_buf if tool_calls: assistant_turn["tool_calls"] = tool_calls convo.append(assistant_turn) @@ -106,7 +148,7 @@ async def run_agent( convo.append( { "role": "tool", - "name": name, + "tool_name": name, "content": json.dumps( { "ok": False, @@ -137,7 +179,7 @@ async def run_agent( convo.append( { "role": "tool", - "name": name, + "tool_name": name, "content": json.dumps(result, ensure_ascii=False), } ) @@ -162,11 +204,21 @@ async def run_agent( yield {"type": "error", "message": f"Ollama : {exc}"} return except (httpx.HTTPError, OSError) as exc: - yield {"type": "error", "message": str(exc)} + yield { + "type": "error", + "message": f"Impossible de joindre Ollama ({ollama.host}) : {exc}", + } return - yield { - "type": "final", - "content": "\n\n".join(text_parts).strip(), - "tools": collected, - } + 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. Essayez un modèle " + "de chat récent ou désactivez son mode de réflexion avancée." + ), + } + return + + yield {"type": "final", "content": final_content, "tools": collected} diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index cbc9ae5..32c2b58 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -9,6 +9,7 @@ Flux : from __future__ import annotations import json +import logging from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse @@ -19,6 +20,7 @@ from ..agent import run_agent from ..config import settings router = APIRouter(prefix="/api", tags=["chat"]) +logger = logging.getLogger(__name__) class ChatRequest(BaseModel): @@ -54,20 +56,38 @@ async def chat(req: ChatRequest) -> StreamingResponse: yield _sse("start", {"model": model}) final_content = "" tools_meta: list[dict] = [] + error_message = "" - async for ev 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), - ): - etype = ev.pop("type") - if etype in ("token", "tool_call", "tool_result", "tool_confirm", "error"): - yield _sse(etype, ev) - elif etype == "final": - final_content = ev["content"] - tools_meta = ev["tools"] + try: + async for ev 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), + ): + etype = ev.pop("type") + if etype in ( + "token", + "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"] + except Exception as exc: + # Après l'envoi des en-têtes SSE, une exception non gérée coupe le + # socket sans explication et laisse l'interface bloquée. + logger.exception("Échec inattendu du flux de chat") + error_message = f"Erreur interne du chat : {exc}" + yield _sse("error", {"message": error_message}) if final_content or tools_meta: db.add_message( @@ -77,6 +97,20 @@ async def chat(req: ChatRequest) -> StreamingResponse: model, meta={"tools": tools_meta} if tools_meta else None, ) - yield _sse("done", {"content": final_content, "tools": tools_meta}) + yield _sse( + "done", + { + "content": final_content, + "tools": tools_meta, + "error": error_message or None, + }, + ) - return StreamingResponse(event_stream(), media_type="text/event-stream") + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + }, + ) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index eedc75e..e19f808 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -176,15 +176,36 @@ export async function streamChat( onToolCall: (call: ToolCall) => void; onToolResult: (call: ToolCall) => void; onToolConfirm: (command: string) => void; + onStatus: (msg: string) => void; + onNotice: (msg: string) => void; onDone: (full: string) => void; onError: (msg: string) => void; } ): Promise { - const res = await fetch("/api/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + let res: Response; + try { + res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } catch (err) { + handlers.onError( + err instanceof Error ? err.message : "serveur Loki injoignable" + ); + return; + } + if (!res.ok) { + let message = `requête refusée (${res.status})`; + try { + const payload = await res.json(); + message = payload.detail ?? payload.error ?? message; + } catch { + /* réponse non JSON */ + } + handlers.onError(message); + return; + } if (!res.body) { handlers.onError("pas de flux de réponse"); return; @@ -193,35 +214,71 @@ export async function streamChat( const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let terminal = false; + let failed = false; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const events = buffer.split("\n\n"); - buffer = events.pop() ?? ""; - - for (const block of events) { - let event = "message"; - let data = ""; - for (const line of block.split("\n")) { - if (line.startsWith("event: ")) event = line.slice(7).trim(); - else if (line.startsWith("data: ")) data += line.slice(6); + const dispatch = (raw: string) => { + const block = raw.replace(/\r\n/g, "\n"); + let event = "message"; + const dataLines: string[] = []; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart()); + } + if (dataLines.length === 0) return; + try { + const payload = JSON.parse(dataLines.join("\n")); + if (event === "token") handlers.onToken(payload.content); + else if (event === "status") handlers.onStatus(payload.message); + else if (event === "notice") handlers.onNotice(payload.message); + else if (event === "tool_call") + handlers.onToolCall({ ...payload, status: "running" }); + else if (event === "tool_result") handlers.onToolResult(payload); + else if (event === "tool_confirm") handlers.onToolConfirm(payload.command); + else if (event === "error") { + failed = true; + handlers.onError(payload.message); + } else if (event === "done") { + terminal = true; + if (payload.error) { + if (!failed) handlers.onError(payload.error); + failed = true; + } else if (!failed) { + handlers.onDone(payload.content); + } } - if (!data) continue; - try { - const payload = JSON.parse(data); - if (event === "token") handlers.onToken(payload.content); - else if (event === "tool_call") - handlers.onToolCall({ ...payload, status: "running" }); - else if (event === "tool_result") handlers.onToolResult(payload); - else if (event === "tool_confirm") handlers.onToolConfirm(payload.command); - else if (event === "done") handlers.onDone(payload.content); - else if (event === "error") handlers.onError(payload.message); - } catch { - /* bloc partiel */ + } catch { + if (!failed) { + failed = true; + handlers.onError("réponse illisible reçue du serveur"); } } + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n"); + const events = buffer.split("\n\n"); + buffer = events.pop() ?? ""; + for (const block of events) { + if (block.trim()) dispatch(block); + } + } + buffer += decoder.decode(); + if (buffer.trim()) dispatch(buffer); + } catch (err) { + if (!failed) { + failed = true; + handlers.onError( + err instanceof Error ? err.message : "connexion au chat interrompue" + ); + } + } + + if (!terminal && !failed) { + handlers.onError("le serveur a fermé la réponse avant sa fin"); } } diff --git a/frontend/src/panels/ChatPanel.tsx b/frontend/src/panels/ChatPanel.tsx index d48e8cb..f4d5bb3 100644 --- a/frontend/src/panels/ChatPanel.tsx +++ b/frontend/src/panels/ChatPanel.tsx @@ -11,6 +11,8 @@ export function ChatPanel() { messages, streaming, streamContent, + streamStatus, + streamNotice, streamTools, sendMessage, currentSessionId, @@ -101,6 +103,8 @@ export function ChatPanel() { created_at: Date.now() / 1000, }} pending + pendingStatus={streamStatus} + notice={streamNotice} /> )} {pendingShell && ( @@ -154,7 +158,17 @@ export function ChatPanel() { ); } -function Bubble({ msg, pending }: { msg: Message; pending?: boolean }) { +function Bubble({ + msg, + pending, + pendingStatus, + notice, +}: { + msg: Message; + pending?: boolean; + pendingStatus?: string; + notice?: string | null; +}) { const time = new Date(msg.created_at * 1000).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", @@ -198,9 +212,16 @@ function Bubble({ msg, pending }: { msg: Message; pending?: boolean }) { {(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => ( ))} + {notice && ( +
+ {notice} +
+ )} {(msg.content || pending) && (
- {msg.content} + {msg.content || ( + {pendingStatus || "Génération…"} + )} {pending && ( )} diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index 9877660..84382b9 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -34,6 +34,8 @@ interface LokiState { messages: Message[]; streaming: boolean; streamContent: string; // réponse de l'agent en cours de frappe + streamStatus: string; + streamNotice: string | null; streamTools: ToolCall[]; // appels d'outils de la réponse en cours fileTree: FileNode[]; @@ -77,6 +79,8 @@ export const useStore = create((set, get) => ({ messages: [], streaming: false, streamContent: "", + streamStatus: "", + streamNotice: null, streamTools: [], fileTree: [], previewPath: null, @@ -154,9 +158,6 @@ export const useStore = create((set, get) => ({ try { const status = await getStatus(); set({ status }); - if (!get().selectedModel && status.default_model) { - set({ selectedModel: status.default_model }); - } } catch { set({ status: { connected: false, host: "", default_model: "" } }); } @@ -166,8 +167,14 @@ export const useStore = create((set, get) => ({ set({ loadingModels: true }); try { const { models, default: def } = await getModels(); - set({ models }); - if (!get().selectedModel && def) set({ selectedModel: def }); + const installed = new Set(models.map((model) => model.name)); + const current = get().selectedModel; + const selectedModel = installed.has(current) + ? current + : installed.has(def) + ? def + : models[0]?.name ?? ""; + set({ models, selectedModel }); } finally { set({ loadingModels: false }); } @@ -180,13 +187,25 @@ export const useStore = create((set, get) => ({ newSession: async () => { const s = await createSession(get().selectedModel || undefined); - set({ currentSessionId: s.id, messages: [], streamContent: "" }); + set({ + currentSessionId: s.id, + messages: [], + streamContent: "", + streamStatus: "", + streamNotice: null, + }); await get().refreshSessions(); }, openSession: async (id) => { const { messages } = await getSession(id); - set({ currentSessionId: id, messages, streamContent: "" }); + set({ + currentSessionId: id, + messages, + streamContent: "", + streamStatus: "", + streamNotice: null, + }); }, removeSession: async (id) => { @@ -201,13 +220,37 @@ export const useStore = create((set, get) => ({ if (get().streaming) return; content = content.trim(); if (!content) return; + if (!get().selectedModel) { + const errMsg: Message = { + id: `err-${Date.now()}`, + session_id: get().currentSessionId ?? "", + role: "assistant", + content: "⚠️ Aucun modèle Ollama installé ou sélectionné.", + created_at: Date.now() / 1000, + }; + set({ messages: [...get().messages, errMsg] }); + return; + } // Crée une session à la volée si aucune n'est ouverte. let sid = get().currentSessionId; - if (!sid) { - const s = await createSession(get().selectedModel || undefined); - sid = s.id; - set({ currentSessionId: s.id }); + try { + if (!sid) { + const s = await createSession(get().selectedModel || undefined); + sid = s.id; + set({ currentSessionId: s.id }); + } + } catch (err) { + const detail = err instanceof Error ? err.message : "backend injoignable"; + const errMsg: Message = { + id: `err-${Date.now()}`, + session_id: "", + role: "assistant", + content: `⚠️ Impossible de créer la session : ${detail}`, + created_at: Date.now() / 1000, + }; + set({ messages: [...get().messages, errMsg] }); + return; } // Affichage optimiste du message utilisateur. @@ -222,6 +265,8 @@ export const useStore = create((set, get) => ({ messages: [...get().messages, userMsg], streaming: true, streamContent: "", + streamStatus: "Connexion à Ollama…", + streamNotice: null, streamTools: [], pendingShell: null, }); @@ -230,6 +275,8 @@ export const useStore = create((set, get) => ({ { session_id: sid, content, model: get().selectedModel || undefined }, { onToken: (t) => set({ streamContent: get().streamContent + t }), + onStatus: (message) => set({ streamStatus: message }), + onNotice: (message) => set({ streamNotice: message }), onToolCall: (call) => set({ streamTools: [...get().streamTools, call] }), onToolResult: (call) => { @@ -254,7 +301,13 @@ export const useStore = create((set, get) => ({ typeof t.args?.path === "string" && /\.html?$/.test(t.args.path as string) ); - set({ streaming: false, streamContent: "", streamTools: [] }); + set({ + streaming: false, + streamContent: "", + streamStatus: "", + streamNotice: null, + streamTools: [], + }); // Recharge depuis la base + l'arborescence (fichiers créés par l'agent). if (get().currentSessionId === sid) await get().openSession(sid!); await get().refreshSessions(); @@ -272,6 +325,8 @@ export const useStore = create((set, get) => ({ set({ streaming: false, streamContent: "", + streamStatus: "", + streamNotice: null, streamTools: [], messages: [...get().messages, errMsg], });