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

+56 -2
View File
@@ -5,12 +5,16 @@ import {
getModels,
getSession,
getStatus,
fileContent,
listFiles,
listSessions,
streamChat,
type FileNode,
type Message,
type OllamaModel,
type OllamaStatus,
type Session,
type ToolCall,
} from "../api/client";
interface LokiState {
@@ -24,10 +28,17 @@ interface LokiState {
messages: Message[];
streaming: boolean;
streamContent: string; // réponse de l'agent en cours de frappe
streamTools: ToolCall[]; // appels d'outils de la réponse en cours
fileTree: FileNode[];
previewPath: string | null;
previewContent: string;
openPreview: (path: string) => Promise<void>;
setSelectedModel: (name: string) => void;
refreshStatus: () => Promise<void>;
refreshModels: () => Promise<void>;
refreshFiles: () => Promise<void>;
refreshSessions: () => Promise<void>;
newSession: () => Promise<void>;
@@ -47,9 +58,26 @@ export const useStore = create<LokiState>((set, get) => ({
messages: [],
streaming: false,
streamContent: "",
streamTools: [],
fileTree: [],
previewPath: null,
previewContent: "",
openPreview: async (path) => {
const content = await fileContent(path);
set({ previewPath: path, previewContent: content });
},
setSelectedModel: (name) => set({ selectedModel: name }),
refreshFiles: async () => {
try {
set({ fileTree: await listFiles() });
} catch {
/* workspace indisponible */
}
},
refreshStatus: async () => {
try {
const status = await getStatus();
@@ -122,17 +150,42 @@ export const useStore = create<LokiState>((set, get) => ({
messages: [...get().messages, userMsg],
streaming: true,
streamContent: "",
streamTools: [],
});
await streamChat(
{ session_id: sid, content, model: get().selectedModel || undefined },
{
onToken: (t) => set({ streamContent: get().streamContent + t }),
onToolCall: (call) =>
set({ streamTools: [...get().streamTools, call] }),
onToolResult: (call) => {
// Met à jour le dernier outil correspondant (statut + résumé).
const tools = [...get().streamTools];
for (let i = tools.length - 1; i >= 0; i--) {
if (tools[i].name === call.name && tools[i].status === "running") {
tools[i] = { ...tools[i], ...call };
break;
}
}
set({ streamTools: tools });
},
onDone: async () => {
set({ streaming: false, streamContent: "" });
// Recharge depuis la base pour récupérer les ids/horodatages réels.
// Repère un fichier HTML écrit pour l'afficher automatiquement.
const writtenHtml = [...get().streamTools]
.reverse()
.find(
(t) =>
t.name === "write_file" &&
typeof t.args?.path === "string" &&
/\.html?$/.test(t.args.path as string)
);
set({ streaming: false, streamContent: "", streamTools: [] });
// Recharge depuis la base + l'arborescence (fichiers créés par l'agent).
if (get().currentSessionId === sid) await get().openSession(sid!);
await get().refreshSessions();
await get().refreshFiles();
if (writtenHtml) await get().openPreview(writtenHtml.args.path as string);
},
onError: (msg) => {
const errMsg: Message = {
@@ -145,6 +198,7 @@ export const useStore = create<LokiState>((set, get) => ({
set({
streaming: false,
streamContent: "",
streamTools: [],
messages: [...get().messages, errMsg],
});
},