Phase 4 : boucle agentique, outils fichiers et aperçu live

Backend :
- tools.py : read_file / write_file / list_dir confinés au workspace (anti-traversal)
- agent.py : boucle de tool-calling itérative au-dessus d'Ollama, génère des
  événements (token, tool_call, tool_result, final)
- routes/chat.py : pilote run_agent, relaie les événements en SSE, persiste la
  réponse finale + le récap des outils (meta)
- routes/files.py : arborescence et contenu du workspace (confiné)
- db.py : colonne meta sur messages (+ migration douce), list_messages_for_model

Frontend :
- ToolCard : carte d'appel d'outil dans le fil (statut en cours/terminé/échec)
- streamChat : événements tool_call / tool_result
- store : streamTools, fileTree, preview (ouverture auto d'un HTML écrit)
- LeftPanel : arborescence réelle du workspace, clic -> aperçu
- PreviewPanel : rendu HTML en iframe + onglet Code
- ChatPanel : rendu des outils dans les bulles agent (streaming + persisté)

Tests manuels : outils + confinement, boucle agent (mock Ollama), chat HTTP de
bout en bout (SSE, persistance meta, fichiers écrits, confinement route).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-29 18:55:16 +00:00
1 parent 48cbcaafaf
commit b6bf4edf97
13 files changed
+677 -80

No files matched your search

+17 -9
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ClipIcon, LokiMark, SendIcon } from "../components/Icon";
import type { Message } from "../api/client";
import { ToolCard } from "../components/ToolCard";
import type { Message, ToolCall } from "../api/client";
/** Panneau central : barre de contexte, fil de conversation, composer. */
export function ChatPanel() {
@@ -10,6 +11,7 @@ export function ChatPanel() {
messages,
streaming,
streamContent,
streamTools,
sendMessage,
currentSessionId,
} = useStore();
@@ -20,7 +22,7 @@ export function ChatPanel() {
// Auto-scroll vers le bas à chaque token / message.
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages, streamContent]);
}, [messages, streamContent, streamTools]);
const submit = () => {
if (!draft.trim() || streaming) return;
@@ -43,7 +45,7 @@ export function ChatPanel() {
<div className="flex h-[42px] flex-none items-center gap-2 border-b border-line-soft px-[18px]">
<Chip>Invite système</Chip>
<Chip>
<span className="h-1.5 w-1.5 rounded-full bg-ok" />0 outil actif
<span className="h-1.5 w-1.5 rounded-full bg-ok" />3 outils actifs
</Chip>
<Chip>
Température <b className="font-semibold text-[#e7ddcd]">0.7</b>
@@ -82,6 +84,7 @@ export function ChatPanel() {
role: "assistant",
content: streamContent,
model: selectedModel,
meta: { tools: streamTools },
created_at: Date.now() / 1000,
}}
pending
@@ -172,12 +175,17 @@ function Bubble({ msg, pending }: { msg: Message; pending?: boolean }) {
{time}
</span>
</div>
<div className="text-sm leading-[1.65] text-ink-2 whitespace-pre-wrap">
{msg.content}
{pending && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-accent align-middle" />
)}
</div>
{(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => (
<ToolCard key={i} call={t} />
))}
{(msg.content || pending) && (
<div className="text-sm leading-[1.65] text-ink-2 whitespace-pre-wrap">
{msg.content}
{pending && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-accent align-middle" />
)}
</div>
)}
</div>
</div>
);
+54 -6
View File
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useStore } from "../store/useStore";
import { PlusIcon } from "../components/Icon";
import type { FileNode } from "../api/client";
/** Panneau gauche : historique des sessions + arborescence de fichiers. */
export function LeftPanel() {
@@ -8,6 +9,8 @@ export function LeftPanel() {
sessions,
currentSessionId,
refreshSessions,
refreshFiles,
fileTree,
newSession,
openSession,
removeSession,
@@ -15,7 +18,8 @@ export function LeftPanel() {
useEffect(() => {
refreshSessions();
}, [refreshSessions]);
refreshFiles();
}, [refreshSessions, refreshFiles]);
return (
<div className="flex w-[268px] flex-none flex-col border-r border-line-soft bg-panel">
@@ -91,16 +95,60 @@ export function LeftPanel() {
</div>
<div className="scr flex-1 overflow-auto px-2.5 pb-3.5 font-mono text-xs">
<div className="px-2 py-4 text-center text-muted-3">
Aucun fichier pour l'instant.
<br />
L'agent créera des fichiers ici.
</div>
{fileTree.length === 0 ? (
<div className="px-2 py-4 text-center text-muted-3">
Aucun fichier pour l'instant.
<br />
L'agent créera des fichiers ici.
</div>
) : (
<FileTree nodes={fileTree} depth={0} />
)}
</div>
</div>
);
}
function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
const openPreview = useStore((s) => s.openPreview);
return (
<>
{nodes.map((n) => (
<div key={n.path}>
<div
onClick={() => n.type === "file" && openPreview(n.path)}
className={`flex items-center gap-2 rounded-[7px] px-2 py-[5px] ${
n.type === "file"
? "cursor-pointer text-ink-2 hover:bg-[#221e18]"
: "text-muted"
}`}
style={{ paddingLeft: 8 + depth * 14 }}
>
{n.type === "dir" ? (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
) : (
<span className="h-1.5 w-1.5 rounded-[1.5px] bg-accent" />
)}
<span className="truncate">{n.name}</span>
</div>
{n.children && <FileTree nodes={n.children} depth={depth + 1} />}
</div>
))}
</>
);
}
function relTime(ts: number): string {
const diff = Date.now() / 1000 - ts;
if (diff < 60) return "à l'instant";
+53 -20
View File
@@ -1,17 +1,25 @@
/** Panneau droit : onglets Aperçu / Code / Logs + zone d'aperçu. */
import { useState } from "react";
import { useStore } from "../store/useStore";
type TabId = "preview" | "code";
/** Panneau droit : onglets Aperçu / Code + rendu du fichier sélectionné. */
export function PreviewPanel() {
const { previewPath, previewContent } = useStore();
const [tab, setTab] = useState<TabId>("preview");
const isHtml = previewPath ? /\.html?$/.test(previewPath) : false;
return (
<div className="flex w-[452px] flex-none flex-col border-l border-line-soft bg-panel">
{/* Onglets */}
<div className="flex h-[42px] flex-none items-center gap-1 border-b border-line-soft px-3.5 pr-2">
<div className="flex gap-0.5">
<Tab active>Aperçu</Tab>
<Tab>Code</Tab>
<Tab>
Logs
<span className="ml-1.5 rounded-[5px] bg-line-strong px-1.5 py-px font-mono text-[10px] text-muted">
0
</span>
<Tab active={tab === "preview"} onClick={() => setTab("preview")}>
Aperçu
</Tab>
<Tab active={tab === "code"} onClick={() => setTab("code")}>
Code
</Tab>
</div>
</div>
@@ -19,36 +27,61 @@ export function PreviewPanel() {
{/* URL bar */}
<div className="flex flex-none items-center gap-2 px-3.5 py-[9px]">
<div className="flex h-[30px] flex-1 items-center gap-2 rounded-lg border border-line bg-sunken px-[11px]">
<span className="font-mono text-[11.5px] text-muted-3">
workspace/
<span className="truncate font-mono text-[11.5px] text-muted-3">
{previewPath ? `workspace/${previewPath}` : "workspace/"}
</span>
</div>
</div>
{/* Viewport */}
<div className="scr mx-3.5 mb-3.5 flex-1 overflow-auto rounded-xl border border-line bg-[#faf6ef]">
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<div className="font-mono text-[11px] text-[#a98b63]">
[ aucun aperçu ]
</div>
<div className="max-w-[240px] text-xs text-[#8a7a66]">
L'aperçu HTML s'affichera ici dès que l'agent générera une page.
</div>
</div>
{!previewPath ? (
<Empty>
L'aperçu s'affichera ici dès que l'agent générera un fichier (clique
aussi un fichier à gauche).
</Empty>
) : tab === "code" ? (
<pre className="m-0 whitespace-pre-wrap p-4 font-mono text-[11.5px] leading-relaxed text-[#2a2018]">
{previewContent}
</pre>
) : isHtml ? (
<iframe
title="aperçu"
srcDoc={previewContent}
className="h-full w-full border-0 bg-white"
sandbox="allow-same-origin"
/>
) : (
<pre className="m-0 whitespace-pre-wrap p-4 font-mono text-[11.5px] leading-relaxed text-[#2a2018]">
{previewContent}
</pre>
)}
</div>
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<div className="font-mono text-[11px] text-[#a98b63]">[ aucun aperçu ]</div>
<div className="max-w-[240px] text-xs text-[#8a7a66]">{children}</div>
</div>
);
}
function Tab({
children,
active,
onClick,
}: {
children: React.ReactNode;
active?: boolean;
onClick?: () => void;
}) {
return (
<div
<button
onClick={onClick}
className={`flex h-7 items-center rounded-lg px-[13px] text-[12.5px] ${
active
? "bg-[#252019] font-semibold text-ink"
@@ -56,6 +89,6 @@ function Tab({
}`}
>
{children}
</div>
</button>
);
}