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

+5 -4
View File
@@ -16,8 +16,9 @@ Frontend (React + Vite + TS + Tailwind)
Backend (FastAPI, Python)
├── /api/status, /api/models, /api/models/pull (Ollama)
├── /api/sessions (CRUD sessions)
├── /api/chat (conversation streaming, SSE)
│ (outils agentiques : phases suivantes)
├── /api/chat (boucle agentique + outils, streaming SSE)
├── /api/files (arborescence + contenu du workspace)
│ Outils agent : read_file · write_file · list_dir (confinés au workspace)
│ httpx │ volume
Ollama (:11434) /workspace + /data (SQLite)
```
@@ -70,8 +71,8 @@ npm run dev # http://localhost:5173
- [x] **Phase 1** — Socle + design system fidèle au thème, layout 3 panneaux
- [x] **Phase 2** — Connexion Ollama : statut, liste des modèles, pull avec progression, sélecteur
- [x] **Phase 3** — Chat streaming (SSE) + persistance des sessions (SQLite)
- [ ] **Phase 4** — Boucle agentique & outils fichiers (read/write/list)
- [ ] **Phase 5** — Aperçu HTML live + onglets Code/Logs + arborescence
- [x] **Phase 4** — Boucle agentique & outils fichiers (read/write/list), confinés au workspace, rendu des appels d'outils dans le fil
- [~] **Phase 5** — Aperçu HTML live + onglet Code + arborescence du workspace (fait) · onglet Logs (à venir)
- [ ] **Phase 6** — Configuration complète (génération, toggles d'outils, invite système)
- [ ] **Phase 7** — Outils avancés (web_search, run_shell confirmé, html_preview)
- [ ] **Phase 8** — Hardening & documentation Docker
+113
View File
@@ -0,0 +1,113 @@
"""Boucle agentique : tool-calling itératif au-dessus d'Ollama.
L'agent dialogue avec le modèle ; quand celui-ci demande un outil, on l'exécute,
on réinjecte le résultat, et on reboucle jusqu'à une réponse finale (ou la
limite d'itérations). La fonction est un générateur asynchrone d'événements
relayés tels quels au client via SSE.
Événements émis :
token {content} — fragment de texte de l'agent
tool_call {name, args} — début d'exécution d'un outil
tool_result {name, args, summary, status}
final {content, tools} — réponse complète + récap des outils
"""
from __future__ import annotations
import json
from typing import AsyncIterator
import httpx
from .ollama_client import ollama
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
MAX_ITERATIONS = 6
def _parse_args(raw) -> dict:
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
return {}
async def run_agent(
model: str,
convo: list[dict],
*,
options: dict | None = None,
tools_enabled: bool = True,
) -> AsyncIterator[dict]:
tools = TOOL_DEFINITIONS if tools_enabled else None
collected: list[dict] = []
text_parts: list[str] = []
try:
for _ in range(MAX_ITERATIONS):
content_buf = ""
tool_calls: list[dict] = []
async for chunk in ollama.chat(
model, convo, tools=tools, options=options, stream=True
):
msg = chunk.get("message", {})
token = msg.get("content", "")
if token:
content_buf += token
yield {"type": "token", "content": token}
if msg.get("tool_calls"):
tool_calls.extend(msg["tool_calls"])
if chunk.get("done"):
break
assistant_turn: dict = {"role": "assistant", "content": content_buf}
if tool_calls:
assistant_turn["tool_calls"] = tool_calls
convo.append(assistant_turn)
if content_buf.strip():
text_parts.append(content_buf.strip())
if not tool_calls:
break
# Exécution des outils demandés, puis réinjection des résultats.
for tc in tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "")
args = _parse_args(fn.get("arguments"))
yield {"type": "tool_call", "name": name, "args": args}
try:
result = run_tool(name, args)
summary = result.get("summary", "terminé")
status = "ok"
except ToolError as exc:
result = {"ok": False, "error": str(exc)}
summary = str(exc)
status = "error"
record = {"name": name, "args": args, "summary": summary, "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append(
{
"role": "tool",
"name": name,
"content": json.dumps(result, ensure_ascii=False),
}
)
except (httpx.HTTPError, OSError) as exc:
yield {"type": "error", "message": str(exc)}
return
yield {
"type": "final",
"content": "\n\n".join(text_parts).strip(),
"tools": collected,
}
+33 -6
View File
@@ -5,6 +5,7 @@ sont rapides ; un verrou protège l'accès concurrent depuis les routes async.
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
@@ -42,12 +43,17 @@ def init_db() -> None:
role TEXT NOT NULL,
content TEXT NOT NULL,
model TEXT,
meta TEXT,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, created_at);
"""
)
# Migration douce : ajoute la colonne meta aux bases antérieures.
cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")}
if "meta" not in cols:
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
def _now() -> float:
@@ -110,20 +116,33 @@ def touch_session(sid: str) -> None:
# ── Messages ─────────────────────────────────────────────────────────────
def add_message(sid: str, role: str, content: str, model: str | None) -> dict:
def add_message(
sid: str,
role: str,
content: str,
model: str | None,
meta: dict | None = None,
) -> dict:
mid = uuid.uuid4().hex
now = _now()
meta_json = json.dumps(meta, ensure_ascii=False) if meta else None
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO messages (id, session_id, role, content, model, created_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(mid, sid, role, content, model, now),
"INSERT INTO messages (id, session_id, role, content, model, meta, created_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(mid, sid, role, content, model, meta_json, now),
)
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?", (now, sid)
)
return {"id": mid, "session_id": sid, "role": role, "content": content,
"model": model, "created_at": now}
"model": model, "meta": meta, "created_at": now}
def _row_to_message(row: sqlite3.Row) -> dict:
msg = dict(row)
msg["meta"] = json.loads(msg["meta"]) if msg.get("meta") else None
return msg
def list_messages(sid: str) -> list[dict]:
@@ -132,4 +151,12 @@ def list_messages(sid: str) -> list[dict]:
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at",
(sid,),
).fetchall()
return [dict(r) for r in rows]
return [_row_to_message(r) for r in rows]
def list_messages_for_model(sid: str) -> list[dict]:
"""Historique épuré (role/content) destiné au contexte du modèle."""
return [
{"role": m["role"], "content": m["content"]}
for m in list_messages(sid)
]
+2 -1
View File
@@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles
from . import db
from .config import settings
from .routes import chat, models, sessions
from .routes import chat, files, models, sessions
@asynccontextmanager
@@ -39,6 +39,7 @@ app.add_middleware(
app.include_router(models.router)
app.include_router(sessions.router)
app.include_router(chat.router)
app.include_router(files.router)
@app.get("/api/health")
+40 -32
View File
@@ -1,29 +1,30 @@
"""Route de conversation : streaming token par token depuis Ollama (SSE).
"""Route de conversation agentique : boucle d'outils + streaming SSE.
Flux :
1. on enregistre le message utilisateur ;
2. on rejoue l'historique de la session vers Ollama en streaming ;
3. on relaie chaque token au client (SSE) ;
4. on enregistre la réponse complète de l'assistant.
2. on lance la boucle agentique (run_agent) sur l'historique de la session ;
3. on relaie tokens et événements d'outils au client (SSE) ;
4. on enregistre la réponse finale de l'assistant + le récap des outils.
"""
from __future__ import annotations
import json
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import db
from ..agent import run_agent
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api", tags=["chat"])
SYSTEM_PROMPT = (
"Tu es Loki, un assistant de développement local. Tu écris du code clair, "
"commenté en français, et tu réponds de façon concise et utile."
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
"d'outils pour lire, écrire et lister des fichiers dans le workspace. "
"Utilise-les pour accomplir les tâches concrètement, puis réponds de façon "
"concise en français. Après avoir écrit un fichier, propose un aperçu."
)
@@ -32,6 +33,7 @@ class ChatRequest(BaseModel):
content: str
model: str | None = None
options: dict | None = None
tools_enabled: bool = True
def _sse(event: str, data: dict) -> str:
@@ -46,39 +48,45 @@ async def chat(req: ChatRequest) -> StreamingResponse:
model = req.model or session.get("model") or settings.default_model
# Premier message : on titre la session avec un extrait.
history = db.list_messages(req.session_id)
if not history:
# Premier message : titre la session avec un extrait.
if not db.list_messages(req.session_id):
title = req.content.strip().split("\n")[0][:60] or "Nouvelle session"
db.rename_session(req.session_id, title)
db.add_message(req.session_id, "user", req.content, None)
# Contexte envoyé à Ollama : invite système + historique complet.
convo = [{"role": "system", "content": SYSTEM_PROMPT}]
convo += [
{"role": m["role"], "content": m["content"]}
for m in db.list_messages(req.session_id)
]
convo += db.list_messages_for_model(req.session_id)
async def event_stream():
yield _sse("start", {"model": model})
full = ""
try:
async for chunk in ollama.chat(
model, convo, options=req.options, stream=True
):
token = chunk.get("message", {}).get("content", "")
if token:
full += token
yield _sse("token", {"content": token})
if chunk.get("done"):
break
except (httpx.HTTPError, OSError) as exc:
yield _sse("error", {"message": str(exc)})
final_content = ""
tools_meta: list[dict] = []
if full:
db.add_message(req.session_id, "assistant", full, model)
yield _sse("done", {"content": full, "model": model})
async for ev in run_agent(
model, convo, options=req.options, tools_enabled=req.tools_enabled
):
etype = ev.pop("type")
if etype == "token":
yield _sse("token", ev)
elif etype == "tool_call":
yield _sse("tool_call", ev)
elif etype == "tool_result":
yield _sse("tool_result", ev)
elif etype == "error":
yield _sse("error", ev)
elif etype == "final":
final_content = ev["content"]
tools_meta = ev["tools"]
if final_content or tools_meta:
db.add_message(
req.session_id,
"assistant",
final_content,
model,
meta={"tools": tools_meta} if tools_meta else None,
)
yield _sse("done", {"content": final_content, "tools": tools_meta})
return StreamingResponse(event_stream(), media_type="text/event-stream")
+49
View File
@@ -0,0 +1,49 @@
"""Routes de lecture du workspace (arborescence + contenu d'un fichier)."""
from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
from ..config import settings
from ..tools import ToolError, _safe_path
router = APIRouter(prefix="/api/files", tags=["files"])
def _tree(path: str) -> list[dict]:
"""Arborescence triée (dossiers d'abord) du workspace."""
items = []
for name in sorted(os.listdir(path)):
if name.startswith("."):
continue
full = os.path.join(path, name)
rel = os.path.relpath(full, os.path.abspath(settings.workspace_dir))
if os.path.isdir(full):
items.append({"name": name, "path": rel, "type": "dir",
"children": _tree(full)})
else:
items.append({"name": name, "path": rel, "type": "file",
"size": os.path.getsize(full)})
# Dossiers avant fichiers
items.sort(key=lambda x: (x["type"] != "dir", x["name"]))
return items
@router.get("")
async def list_files() -> dict:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return {"tree": _tree(root)}
@router.get("/content")
async def file_content(path: str) -> dict:
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc))
if not os.path.isfile(target):
raise HTTPException(404, "fichier introuvable")
with open(target, "r", encoding="utf-8", errors="replace") as f:
return {"path": path, "content": f.read()}
+139
View File
@@ -0,0 +1,139 @@
"""Outils de l'agent, exécutés côté serveur et confinés au workspace.
Chaque outil expose :
- une définition JSON (format function-calling Ollama/OpenAI) ;
- une implémentation Python qui renvoie un dict {ok, summary, ...}.
Toutes les opérations fichier sont strictement confinées à WORKSPACE_DIR :
toute tentative de sortie (../, chemin absolu hors workspace) est rejetée.
"""
from __future__ import annotations
import os
from .config import settings
class ToolError(Exception):
"""Erreur d'exécution d'un outil (message destiné au modèle)."""
def _workspace_root() -> str:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return root
def _safe_path(rel: str) -> str:
"""Résout un chemin relatif en restant confiné au workspace."""
root = _workspace_root()
target = os.path.abspath(os.path.join(root, rel or "."))
if target != root and not target.startswith(root + os.sep):
raise ToolError(f"chemin hors du workspace refusé : {rel}")
return target
# ── Implémentations ──────────────────────────────────────────────────────
def read_file(path: str) -> dict:
target = _safe_path(path)
if not os.path.isfile(target):
raise ToolError(f"fichier introuvable : {path}")
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
size = os.path.getsize(target)
summary = "fichier vide (0 octet)" if size == 0 else f"{len(content.splitlines())} lignes lues"
return {"ok": True, "content": content, "summary": summary}
def write_file(path: str, content: str) -> dict:
target = _safe_path(path)
os.makedirs(os.path.dirname(target) or _workspace_root(), exist_ok=True)
existed = os.path.isfile(target)
with open(target, "w", encoding="utf-8") as f:
f.write(content)
lines = len(content.splitlines())
verb = "modifié" if existed else "écrit"
return {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
def list_dir(path: str = ".") -> dict:
target = _safe_path(path)
if not os.path.isdir(target):
raise ToolError(f"répertoire introuvable : {path}")
entries = []
for name in sorted(os.listdir(target)):
full = os.path.join(target, name)
entries.append({"name": name, "type": "dir" if os.path.isdir(full) else "file"})
return {
"ok": True,
"entries": entries,
"summary": f"{len(entries)} élément(s)",
}
# ── Registre & définitions exposées au modèle ────────────────────────────
TOOL_IMPL = {
"read_file": read_file,
"write_file": write_file,
"list_dir": list_dir,
}
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Lire le contenu d'un fichier du workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Créer ou modifier un fichier du workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"},
"content": {"type": "string", "description": "Contenu complet du fichier"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "Lister le contenu d'un répertoire du workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Répertoire (défaut : racine)"}
},
},
},
},
]
def run_tool(name: str, args: dict) -> dict:
"""Exécute un outil par son nom ; lève ToolError si inconnu/invalide."""
impl = TOOL_IMPL.get(name)
if impl is None:
raise ToolError(f"outil inconnu : {name}")
try:
return impl(**(args or {}))
except ToolError:
raise
except TypeError as exc:
raise ToolError(f"arguments invalides pour {name} : {exc}") from exc
except OSError as exc:
raise ToolError(f"erreur système ({name}) : {exc}") from exc
+32
View File
@@ -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 {
+84
View File
@@ -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>
);
}
+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>
);
}
+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],
});
},