diff --git a/backend/app/agent.py b/backend/app/agent.py index f117c54..c456896 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio import json +import re from typing import AsyncIterator import httpx @@ -99,6 +100,27 @@ def _parse_args(raw) -> dict: 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], @@ -109,6 +131,7 @@ async def run_agent( 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: @@ -142,6 +165,10 @@ async def run_agent( # 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} @@ -202,6 +229,14 @@ async def run_agent( 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"): diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 86a41f2..9d0ef73 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -455,8 +455,14 @@ async def chat(req: ChatRequest) -> StreamingResponse: if plan: convo.append({ "role": "system", - "content": "Plan à suivre pour cette demande :\n" - + "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan)), + "content": ( + "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." + ), }) final_content = "" @@ -478,6 +484,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: 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: @@ -514,6 +521,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: "tool_call", "tool_result", "tool_confirm", + "plan_step", ): yield _sse(etype, ev) elif etype == "error": diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 297f47f..6aa88be 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -575,6 +575,7 @@ export async function streamChat( onStatus: (msg: string) => void; onNotice: (msg: string) => void; onPlan?: (steps: string[]) => void; + onPlanStep?: (index: number) => void; onRevision?: (content: string) => void; onDone: (full: string) => void; onError: (msg: string) => void; @@ -638,6 +639,7 @@ export async function streamChat( const payload = JSON.parse(dataLines.join("\n")); if (event === "token") handlers.onToken(payload.content); else if (event === "plan") handlers.onPlan?.(payload.steps); + else if (event === "plan_step") handlers.onPlanStep?.(payload.index); else if (event === "revision") handlers.onRevision?.(payload.content); else if (event === "thinking") handlers.onThinking(payload.content); else if (event === "status") handlers.onStatus(payload.message); diff --git a/frontend/src/panels/ChatPanel.tsx b/frontend/src/panels/ChatPanel.tsx index 158f035..b12e565 100644 --- a/frontend/src/panels/ChatPanel.tsx +++ b/frontend/src/panels/ChatPanel.tsx @@ -20,6 +20,7 @@ export function ChatPanel() { streamNotice, streamTools, streamPlan, + streamPlanDone, sendMessage, currentSessionId, config, @@ -40,7 +41,7 @@ export function ChatPanel() { // Auto-scroll vers le bas à chaque token / message. useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [messages, streamContent, streamThinking, streamTools, streamPlan]); + }, [messages, streamContent, streamThinking, streamTools, streamPlan, streamPlanDone]); const submit = () => { if (!draft.trim() || streaming) return; @@ -111,6 +112,7 @@ export function ChatPanel() { pendingStatus={streamStatus} notice={streamNotice} thinking={streamThinking} + planDone={streamPlanDone} /> )} {showingStreaming && pendingShell && ( @@ -234,7 +236,22 @@ function ModeSelector() { ); } -function PlanCard({ steps }: { steps: string[] }) { +function PlanCard({ + steps, + done, + live, +}: { + steps: string[]; + done?: number[]; + live?: boolean; +}) { + const doneSet = new Set(done ?? []); + // Étape active = la première non validée, uniquement pendant le stream. + const activeIdx = live + ? steps.findIndex((_, i) => !doneSet.has(i)) + : -1; + const doneCount = doneSet.size; + return (