mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
5 files changed
+112
-15
No files matched your search
@@ -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"):
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user