diff --git a/README.md b/README.md index 9189c4a..767bfdf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/agent.py b/backend/app/agent.py new file mode 100644 index 0000000..dad3b8e --- /dev/null +++ b/backend/app/agent.py @@ -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, + } diff --git a/backend/app/db.py b/backend/app/db.py index 4a6b859..17641d0 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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) + ] diff --git a/backend/app/main.py b/backend/app/main.py index 353e62f..5b8700a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 2e13b21..d63ad42 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -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") diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py new file mode 100644 index 0000000..45a7f04 --- /dev/null +++ b/backend/app/routes/files.py @@ -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()} diff --git a/backend/app/tools.py b/backend/app/tools.py new file mode 100644 index 0000000..aa42a54 --- /dev/null +++ b/backend/app/tools.py @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 75ec6df..15f9c10 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -38,15 +38,42 @@ export interface Session { message_count?: number; } +export interface ToolCall { + name: string; + args: Record; + 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 { + const res = await fetch("/api/files"); + return (await res.json()).tree; +} + +export async function fileContent(path: string): Promise { + const res = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`); + if (!res.ok) return ""; + return (await res.json()).content; +} + export async function listSessions(): Promise { 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 { diff --git a/frontend/src/components/ToolCard.tsx b/frontend/src/components/ToolCard.tsx new file mode 100644 index 0000000..a1562ba --- /dev/null +++ b/frontend/src/components/ToolCard.tsx @@ -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 ( +
+
+ + + + + {call.name} + + {argPreview} +
+ {running ? ( + + + en cours + + ) : ( + + {error ? "✕ échec" : "✓ terminé"} + + )} +
+ {call.summary && !running && ( +
+ → {call.summary} +
+ )} +
+ ); +} + +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 ( + + + + ); + if (name === "list_dir") + return ( + + + + ); + // read_file (défaut) + return ( + + + + + ); +} diff --git a/frontend/src/panels/ChatPanel.tsx b/frontend/src/panels/ChatPanel.tsx index f5f0a7a..b94b86e 100644 --- a/frontend/src/panels/ChatPanel.tsx +++ b/frontend/src/panels/ChatPanel.tsx @@ -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() {
Invite système - 0 outil actif + 3 outils actifs Température 0.7 @@ -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}
-
- {msg.content} - {pending && ( - - )} -
+ {(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => ( + + ))} + {(msg.content || pending) && ( +
+ {msg.content} + {pending && ( + + )} +
+ )}
); diff --git a/frontend/src/panels/LeftPanel.tsx b/frontend/src/panels/LeftPanel.tsx index cc5f6e4..cccafa8 100644 --- a/frontend/src/panels/LeftPanel.tsx +++ b/frontend/src/panels/LeftPanel.tsx @@ -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 (
@@ -91,16 +95,60 @@ export function LeftPanel() {
-
- Aucun fichier pour l'instant. -
- L'agent créera des fichiers ici. -
+ {fileTree.length === 0 ? ( +
+ Aucun fichier pour l'instant. +
+ L'agent créera des fichiers ici. +
+ ) : ( + + )}
); } +function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) { + const openPreview = useStore((s) => s.openPreview); + return ( + <> + {nodes.map((n) => ( +
+
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" ? ( + + + + ) : ( + + )} + {n.name} +
+ {n.children && } +
+ ))} + + ); +} + function relTime(ts: number): string { const diff = Date.now() / 1000 - ts; if (diff < 60) return "à l'instant"; diff --git a/frontend/src/panels/PreviewPanel.tsx b/frontend/src/panels/PreviewPanel.tsx index 3882d6f..2d5fed9 100644 --- a/frontend/src/panels/PreviewPanel.tsx +++ b/frontend/src/panels/PreviewPanel.tsx @@ -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("preview"); + + const isHtml = previewPath ? /\.html?$/.test(previewPath) : false; + return (
{/* Onglets */}
- Aperçu - Code - - Logs - - 0 - + setTab("preview")}> + Aperçu + + setTab("code")}> + Code
@@ -19,36 +27,61 @@ export function PreviewPanel() { {/* URL bar */}
- - workspace/ + + {previewPath ? `workspace/${previewPath}` : "workspace/"}
{/* Viewport */}
-
-
- [ aucun aperçu ] -
-
- L'aperçu HTML s'affichera ici dès que l'agent générera une page. -
-
+ {!previewPath ? ( + + L'aperçu s'affichera ici dès que l'agent générera un fichier (clique + aussi un fichier à gauche). + + ) : tab === "code" ? ( +
+            {previewContent}
+          
+ ) : isHtml ? ( +