mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
48cbcaafaf
commit
b6bf4edf97
13 files changed
+677
-80
No files matched your search
@@ -38,15 +38,42 @@ export interface Session {
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
summary?: string;
|
||||
status?: "ok" | "error" | "running";
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
session_id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
model?: string;
|
||||
meta?: { tools?: ToolCall[] } | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "dir" | "file";
|
||||
size?: number;
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
export async function listFiles(): Promise<FileNode[]> {
|
||||
const res = await fetch("/api/files");
|
||||
return (await res.json()).tree;
|
||||
}
|
||||
|
||||
export async function fileContent(path: string): Promise<string> {
|
||||
const res = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) return "";
|
||||
return (await res.json()).content;
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<Session[]> {
|
||||
const res = await fetch("/api/sessions");
|
||||
return (await res.json()).sessions;
|
||||
@@ -77,6 +104,8 @@ export async function streamChat(
|
||||
body: { session_id: string; content: string; model?: string },
|
||||
handlers: {
|
||||
onToken: (t: string) => void;
|
||||
onToolCall: (call: ToolCall) => void;
|
||||
onToolResult: (call: ToolCall) => void;
|
||||
onDone: (full: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
@@ -113,6 +142,9 @@ export async function streamChat(
|
||||
try {
|
||||
const payload = JSON.parse(data);
|
||||
if (event === "token") handlers.onToken(payload.content);
|
||||
else if (event === "tool_call")
|
||||
handlers.onToolCall({ ...payload, status: "running" });
|
||||
else if (event === "tool_result") handlers.onToolResult(payload);
|
||||
else if (event === "done") handlers.onDone(payload.content);
|
||||
else if (event === "error") handlers.onError(payload.message);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ToolCall } from "../api/client";
|
||||
|
||||
/** Carte d'appel d'outil rendue dans le fil (fidèle à la maquette). */
|
||||
export function ToolCard({ call }: { call: ToolCall }) {
|
||||
const running = call.status === "running";
|
||||
const error = call.status === "error";
|
||||
|
||||
// Argument principal affiché entre parenthèses (path le plus souvent).
|
||||
const argPreview =
|
||||
typeof call.args?.path === "string"
|
||||
? `("${call.args.path}")`
|
||||
: Object.keys(call.args ?? {}).length
|
||||
? "(…)"
|
||||
: "()";
|
||||
|
||||
return (
|
||||
<div className="mb-[9px] overflow-hidden rounded-[11px] border border-line bg-card-deep">
|
||||
<div className="flex items-center gap-2.5 px-[13px] py-2.5">
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-[7px] text-accent"
|
||||
style={{ background: "rgba(240,161,92,.13)" }}
|
||||
>
|
||||
<ToolGlyph name={call.name} />
|
||||
</span>
|
||||
<span className="font-mono text-[12.5px] font-semibold text-[#e7ddcd]">
|
||||
{call.name}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-2">{argPreview}</span>
|
||||
<div className="flex-1" />
|
||||
{running ? (
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] text-muted">
|
||||
<span className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-muted-3 border-t-accent" />
|
||||
en cours
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`flex items-center gap-1.5 font-mono text-[11px] ${
|
||||
error ? "text-warn" : "text-ok"
|
||||
}`}
|
||||
>
|
||||
{error ? "✕ échec" : "✓ terminé"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{call.summary && !running && (
|
||||
<div className="border-t border-line-soft px-[13px] py-2.5 font-mono text-[11.5px] text-muted-3">
|
||||
→ {call.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolGlyph({ name }: { name: string }) {
|
||||
const common = {
|
||||
width: 13,
|
||||
height: 13,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.9,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
if (name === "write_file")
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M5 19h14M7 14l9-9 3 3-9 9-4 1z" />
|
||||
</svg>
|
||||
);
|
||||
if (name === "list_dir")
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 7h6l2 2h8v9H4z" />
|
||||
</svg>
|
||||
);
|
||||
// read_file (défaut)
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 4h7l4 4v12H7z" />
|
||||
<path d="M14 4v4h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user