From 54921053c4cae7ff73a1ba90b7c276c9b770ba75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 12:45:32 +0000 Subject: [PATCH] =?UTF-8?q?Plan=20:=20validation=20des=20=C3=A9tapes=20en?= =?UTF-8?q?=20direct=20(point=20par=20point)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le plan s'affichait mais restait figé — on ne voyait pas le modèle avancer. Désormais : - Le modèle annonce chaque étape accomplie (« ✅ Étape N terminée »), demandé dans la consigne de plan. - L'agent détecte ces annonces dans le flux et émet des événements plan_step, sans jamais recompter deux fois la même étape. - Le panneau PLAN coche les étapes en temps réel : ✓ vert + barré pour les faites, sablier sur l'étape en cours, compteur « n/N validées ». Chemin agent uniquement (le moteur code tourne de bout en bout). Les messages passés restent affichés tels quels. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N --- backend/app/agent.py | 35 +++++++++++++++++ backend/app/routes/chat.py | 12 +++++- frontend/src/api/client.ts | 2 + frontend/src/panels/ChatPanel.tsx | 64 +++++++++++++++++++++++++------ frontend/src/store/useStore.ts | 14 ++++++- 5 files changed, 112 insertions(+), 15 deletions(-) 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 (
PLAN - {steps.length} étape{steps.length > 1 ? "s" : ""} + {doneCount > 0 + ? `${doneCount}/${steps.length} validée${doneCount > 1 ? "s" : ""}` + : `${steps.length} étape${steps.length > 1 ? "s" : ""}`}
    - {steps.map((s, i) => ( -
  1. - - {i + 1} - - {s} -
  2. - ))} + {steps.map((s, i) => { + const isDone = doneSet.has(i); + const isActive = i === activeIdx; + return ( +
  3. + + {isDone ? "✓" : isActive ? "…" : i + 1} + + + {s} + +
  4. + ); + })}
); @@ -266,12 +304,14 @@ function Bubble({ pendingStatus, notice, thinking, + planDone, }: { msg: Message; pending?: boolean; pendingStatus?: string; notice?: string | null; thinking?: string; + planDone?: number[]; }) { const time = new Date(msg.created_at * 1000).toLocaleTimeString("fr-FR", { hour: "2-digit", @@ -315,7 +355,7 @@ function Bubble({ live={!!pending} /> {(msg.meta?.plan?.length ?? 0) > 0 && ( - + )} {(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => ( diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index 2b79ae5..bfa1e19 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -51,6 +51,7 @@ interface LokiState { streamNotice: string | null; streamTools: ToolCall[]; // appels d'outils de la réponse en cours streamPlan: string[]; // plan de la réponse en cours + streamPlanDone: number[]; // index des étapes validées en direct fileTree: FileNode[]; previewPath: string | null; @@ -114,6 +115,7 @@ export const useStore = create((set, get) => ({ streamNotice: null, streamTools: [], streamPlan: [], + streamPlanDone: [], fileTree: [], previewPath: null, previewContent: "", @@ -410,6 +412,8 @@ export const useStore = create((set, get) => ({ streamStatus: "Connexion à Ollama…", streamNotice: null, streamTools: [], + streamPlan: [], + streamPlanDone: [], pendingShell: null, }); @@ -427,7 +431,13 @@ export const useStore = create((set, get) => ({ onToken: (t) => set({ streamContent: get().streamContent + t }), onThinking: (t) => set({ streamThinking: get().streamThinking + t }), onStatus: (message) => set({ streamStatus: message }), - onPlan: (steps) => set({ streamPlan: steps }), + onPlan: (steps) => set({ streamPlan: steps, streamPlanDone: [] }), + onPlanStep: (index) => + set({ + streamPlanDone: get().streamPlanDone.includes(index) + ? get().streamPlanDone + : [...get().streamPlanDone, index], + }), onRevision: (content) => set({ streamContent: content }), onNotice: (message) => set({ streamNotice: message }), onToolCall: (call) => @@ -463,6 +473,8 @@ export const useStore = create((set, get) => ({ streamStatus: "", streamNotice: null, streamTools: [], + streamPlan: [], + streamPlanDone: [], }); // Recharge depuis la base + l'arborescence (fichiers créés par // l'agent) — trois requêtes indépendantes, en parallèle.