Plan : validation des étapes en direct (point par point)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
Claude committed 2026-07-20 12:45:32 +00:00
1 parent de4ec1d919
commit 54921053c4
5 files changed
+112 -15

No files matched your search

+35
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re
from typing import AsyncIterator from typing import AsyncIterator
import httpx import httpx
@@ -99,6 +100,27 @@ def _parse_args(raw) -> dict:
return {} 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( async def run_agent(
model: str, model: str,
convo: list[dict], convo: list[dict],
@@ -109,6 +131,7 @@ async def run_agent(
think: bool = True, think: bool = True,
keep_alive: str | None = None, keep_alive: str | None = None,
mcp_tools: list[dict] | None = None, mcp_tools: list[dict] | None = None,
plan: list[str] | None = None,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
# enabled_tools=None -> tous les outils ; liste vide -> aucun outil. # enabled_tools=None -> tous les outils ; liste vide -> aucun outil.
if enabled_tools is None: 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. # Compteur d'itérations où le modèle n'a produit QUE du raisonnement.
thinking_only_strikes = 0 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 # 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. # les renvoie dans le chunk final (done=true) de chaque génération.
stats = {"eval_count": 0, "eval_duration": 0, "prompt_eval_count": 0} stats = {"eval_count": 0, "eval_duration": 0, "prompt_eval_count": 0}
@@ -202,6 +229,14 @@ async def run_agent(
if token: if token:
content_buf += token content_buf += token
yield {"type": "token", "content": 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"): if msg.get("tool_calls"):
tool_calls.extend(msg["tool_calls"]) tool_calls.extend(msg["tool_calls"])
if chunk.get("done"): if chunk.get("done"):
+10 -2
View File
@@ -455,8 +455,14 @@ async def chat(req: ChatRequest) -> StreamingResponse:
if plan: if plan:
convo.append({ convo.append({
"role": "system", "role": "system",
"content": "Plan à suivre pour cette demande :\n" "content": (
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan)), "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 = "" final_content = ""
@@ -478,6 +484,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
think=cfg.get("think", True), think=cfg.get("think", True),
keep_alive=cfg.get("keep_alive", "30m"), keep_alive=cfg.get("keep_alive", "30m"),
mcp_tools=mcp_tools, mcp_tools=mcp_tools,
plan=plan,
): ):
await queue.put(event) await queue.put(event)
except Exception as exc: except Exception as exc:
@@ -514,6 +521,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
"tool_call", "tool_call",
"tool_result", "tool_result",
"tool_confirm", "tool_confirm",
"plan_step",
): ):
yield _sse(etype, ev) yield _sse(etype, ev)
elif etype == "error": elif etype == "error":
+2
View File
@@ -575,6 +575,7 @@ export async function streamChat(
onStatus: (msg: string) => void; onStatus: (msg: string) => void;
onNotice: (msg: string) => void; onNotice: (msg: string) => void;
onPlan?: (steps: string[]) => void; onPlan?: (steps: string[]) => void;
onPlanStep?: (index: number) => void;
onRevision?: (content: string) => void; onRevision?: (content: string) => void;
onDone: (full: string) => void; onDone: (full: string) => void;
onError: (msg: string) => void; onError: (msg: string) => void;
@@ -638,6 +639,7 @@ export async function streamChat(
const payload = JSON.parse(dataLines.join("\n")); const payload = JSON.parse(dataLines.join("\n"));
if (event === "token") handlers.onToken(payload.content); if (event === "token") handlers.onToken(payload.content);
else if (event === "plan") handlers.onPlan?.(payload.steps); 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 === "revision") handlers.onRevision?.(payload.content);
else if (event === "thinking") handlers.onThinking(payload.content); else if (event === "thinking") handlers.onThinking(payload.content);
else if (event === "status") handlers.onStatus(payload.message); else if (event === "status") handlers.onStatus(payload.message);
+52 -12
View File
@@ -20,6 +20,7 @@ export function ChatPanel() {
streamNotice, streamNotice,
streamTools, streamTools,
streamPlan, streamPlan,
streamPlanDone,
sendMessage, sendMessage,
currentSessionId, currentSessionId,
config, config,
@@ -40,7 +41,7 @@ export function ChatPanel() {
// Auto-scroll vers le bas à chaque token / message. // Auto-scroll vers le bas à chaque token / message.
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages, streamContent, streamThinking, streamTools, streamPlan]); }, [messages, streamContent, streamThinking, streamTools, streamPlan, streamPlanDone]);
const submit = () => { const submit = () => {
if (!draft.trim() || streaming) return; if (!draft.trim() || streaming) return;
@@ -111,6 +112,7 @@ export function ChatPanel() {
pendingStatus={streamStatus} pendingStatus={streamStatus}
notice={streamNotice} notice={streamNotice}
thinking={streamThinking} thinking={streamThinking}
planDone={streamPlanDone}
/> />
)} )}
{showingStreaming && pendingShell && ( {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 ( return (
<div <div
className="mb-[11px] overflow-hidden border-[3px] border-line bg-card shadow-hard-sm" className="mb-[11px] overflow-hidden border-[3px] border-line bg-card shadow-hard-sm"
@@ -243,18 +260,39 @@ function PlanCard({ steps }: { steps: string[] }) {
<div className="flex items-center gap-2 border-b-2 border-line-soft px-3 py-2"> <div className="flex items-center gap-2 border-b-2 border-line-soft px-3 py-2">
<span className="font-pixel text-[9px] text-accent">PLAN</span> <span className="font-pixel text-[9px] text-accent">PLAN</span>
<span className="text-[12px] text-muted-2"> <span className="text-[12px] text-muted-2">
{steps.length} étape{steps.length > 1 ? "s" : ""} {doneCount > 0
? `${doneCount}/${steps.length} validée${doneCount > 1 ? "s" : ""}`
: `${steps.length} étape${steps.length > 1 ? "s" : ""}`}
</span> </span>
</div> </div>
<ol className="m-0 list-none px-3 py-2"> <ol className="m-0 list-none px-3 py-2">
{steps.map((s, i) => ( {steps.map((s, i) => {
<li key={i} className="flex gap-2 py-[3px] text-[13px] text-ink-2"> const isDone = doneSet.has(i);
<span className="flex h-[18px] w-[18px] flex-none items-center justify-center border-2 border-line bg-base text-[11px] text-ink"> const isActive = i === activeIdx;
{i + 1} return (
</span> <li
<span className="min-w-0">{s}</span> key={i}
</li> className={`flex gap-2 py-[3px] text-[13px] ${
))} isDone ? "text-muted-2" : "text-ink-2"
}`}
>
<span
className={`flex h-[18px] w-[18px] flex-none items-center justify-center border-2 text-[11px] ${
isDone
? "border-ok bg-ok text-white"
: isActive
? "border-accent bg-base text-accent"
: "border-line bg-base text-ink"
}`}
>
{isDone ? "✓" : isActive ? "…" : i + 1}
</span>
<span className={`min-w-0 ${isDone ? "line-through" : ""}`}>
{s}
</span>
</li>
);
})}
</ol> </ol>
</div> </div>
); );
@@ -266,12 +304,14 @@ function Bubble({
pendingStatus, pendingStatus,
notice, notice,
thinking, thinking,
planDone,
}: { }: {
msg: Message; msg: Message;
pending?: boolean; pending?: boolean;
pendingStatus?: string; pendingStatus?: string;
notice?: string | null; notice?: string | null;
thinking?: string; thinking?: string;
planDone?: number[];
}) { }) {
const time = new Date(msg.created_at * 1000).toLocaleTimeString("fr-FR", { const time = new Date(msg.created_at * 1000).toLocaleTimeString("fr-FR", {
hour: "2-digit", hour: "2-digit",
@@ -315,7 +355,7 @@ function Bubble({
live={!!pending} live={!!pending}
/> />
{(msg.meta?.plan?.length ?? 0) > 0 && ( {(msg.meta?.plan?.length ?? 0) > 0 && (
<PlanCard steps={msg.meta!.plan!} /> <PlanCard steps={msg.meta!.plan!} done={planDone} live={!!pending} />
)} )}
{(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => ( {(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => (
<ToolCard key={i} call={t} /> <ToolCard key={i} call={t} />
+13 -1
View File
@@ -51,6 +51,7 @@ interface LokiState {
streamNotice: string | null; streamNotice: string | null;
streamTools: ToolCall[]; // appels d'outils de la réponse en cours streamTools: ToolCall[]; // appels d'outils de la réponse en cours
streamPlan: string[]; // plan 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[]; fileTree: FileNode[];
previewPath: string | null; previewPath: string | null;
@@ -114,6 +115,7 @@ export const useStore = create<LokiState>((set, get) => ({
streamNotice: null, streamNotice: null,
streamTools: [], streamTools: [],
streamPlan: [], streamPlan: [],
streamPlanDone: [],
fileTree: [], fileTree: [],
previewPath: null, previewPath: null,
previewContent: "", previewContent: "",
@@ -410,6 +412,8 @@ export const useStore = create<LokiState>((set, get) => ({
streamStatus: "Connexion à Ollama…", streamStatus: "Connexion à Ollama…",
streamNotice: null, streamNotice: null,
streamTools: [], streamTools: [],
streamPlan: [],
streamPlanDone: [],
pendingShell: null, pendingShell: null,
}); });
@@ -427,7 +431,13 @@ export const useStore = create<LokiState>((set, get) => ({
onToken: (t) => set({ streamContent: get().streamContent + t }), onToken: (t) => set({ streamContent: get().streamContent + t }),
onThinking: (t) => set({ streamThinking: get().streamThinking + t }), onThinking: (t) => set({ streamThinking: get().streamThinking + t }),
onStatus: (message) => set({ streamStatus: message }), 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 }), onRevision: (content) => set({ streamContent: content }),
onNotice: (message) => set({ streamNotice: message }), onNotice: (message) => set({ streamNotice: message }),
onToolCall: (call) => onToolCall: (call) =>
@@ -463,6 +473,8 @@ export const useStore = create<LokiState>((set, get) => ({
streamStatus: "", streamStatus: "",
streamNotice: null, streamNotice: null,
streamTools: [], streamTools: [],
streamPlan: [],
streamPlanDone: [],
}); });
// Recharge depuis la base + l'arborescence (fichiers créés par // Recharge depuis la base + l'arborescence (fichiers créés par
// l'agent) — trois requêtes indépendantes, en parallèle. // l'agent) — trois requêtes indépendantes, en parallèle.