diff --git a/README.md b/README.md index 25a26ce..35fe137 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,21 @@ message suivant paie alors un rechargement complet (lent). Loki évite ça : - **Indicateur d'état** : la pastille du sélecteur de modèle est verte quand le modèle est chargé sur GPU, orange sur CPU, blanche s'il reste à charger. +## Modes d'exécution (Plan / Build / Yolo) + +Un sélecteur dans le composer contrôle le niveau d'autonomie de l'agent : +- **Plan** 🔍 — lecture seule (`read_file`, `list_dir`, `grep_search`) : l'agent + analyse et propose sans jamais modifier de fichier ni exécuter de commande. +- **Build** 🔨 — normal : écrit les fichiers, `run_shell` demande confirmation. +- **Yolo** ⚡ — autonomie maximale : approuve tout, y compris le shell. + +## Panneau Git & Diff + +Le workspace étant un dépôt git (chaque action de l'agent = un commit), l'onglet +**Git** du panneau de droite montre l'historique des commits, le **diff coloré** +de chacun, et un bouton **↶ Annuler** (revert) pour défaire une modification en +un clic. + ## Intelligence augmentée - **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes diff --git a/backend/app/main.py b/backend/app/main.py index 63e6abd..aa94cb0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from . import agent_config, coder, db, rag from .config import settings -from .routes import benchmark, chat, config, files, models, sessions, shell, system +from .routes import benchmark, chat, config, files, git, models, sessions, shell, system async def _warm_default_model() -> None: @@ -71,6 +71,7 @@ app.include_router(config.router) app.include_router(shell.router) app.include_router(system.router) app.include_router(benchmark.router) +app.include_router(git.router) @app.get("/api/health") diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 83e3ff2..0b2f3b0 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -31,6 +31,29 @@ class ChatRequest(BaseModel): session_id: str content: str model: str | None = None + # Mode d'exécution : "plan" (lecture seule), "build" (normal), "yolo" (auto). + mode: str = "build" + + +# Outils autorisés en mode Plan : lecture/analyse uniquement. +_READONLY_TOOLS = {"read_file", "list_dir", "grep_search"} + + +def _apply_mode(cfg: dict, mode: str) -> dict: + """Adapte la config au mode d'exécution choisi dans le composer.""" + cfg = dict(cfg) + if mode == "plan": + # Lecture seule : aucune écriture, aucun shell, aucun moteur code. + cfg["tools"] = { + name: (on and name in _READONLY_TOOLS) + for name, on in cfg["tools"].items() + } + cfg["plan_mode"] = True + elif mode == "yolo": + # Autonomie maximale : plus de validation shell. + cfg["confirm_shell"] = False + # "build" : comportement par défaut (inchangé). + return cfg def _sse(event: str, data: dict) -> str: @@ -153,7 +176,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: raise HTTPException(404, "session introuvable") model = req.model or session.get("model") or settings.default_model - cfg = agent_config.get_config(model) + cfg = _apply_mode(agent_config.get_config(model), req.mode) # Premier message : titre la session avec un extrait. if not db.list_messages(req.session_id): diff --git a/backend/app/routes/git.py b/backend/app/routes/git.py new file mode 100644 index 0000000..d659ccb --- /dev/null +++ b/backend/app/routes/git.py @@ -0,0 +1,92 @@ +"""Routes Git du workspace : historique, diff, retour arrière. + +Le workspace est un dépôt git (Aider commite chaque modification). Ces routes +donnent à l'UI un panneau Git : voir les commits, leur diff, et annuler une +modification. Toutes les commandes sont exécutées DANS le workspace. +""" +from __future__ import annotations + +import subprocess + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ..coder import ensure_git +from ..config import settings + +router = APIRouter(prefix="/api/git", tags=["git"]) + + +def _git(*args: str, timeout: int = 15) -> subprocess.CompletedProcess: + """Exécute une commande git dans le workspace.""" + ensure_git(settings.workspace_dir) + return subprocess.run( + ["git", *args], + cwd=settings.workspace_dir, + capture_output=True, + text=True, + timeout=timeout, + ) + + +@router.get("/log") +async def git_log(limit: int = 40) -> dict: + """Historique des commits (hash court, message, auteur, date, nb fichiers).""" + fmt = "%h%x1f%s%x1f%an%x1f%ar%x1f%H" + proc = _git("log", f"-{max(1, min(limit, 200))}", f"--pretty=format:{fmt}") + if proc.returncode != 0: + # Dépôt sans commit encore. + return {"commits": []} + + commits = [] + for line in proc.stdout.splitlines(): + parts = line.split("\x1f") + if len(parts) != 5: + continue + short, subject, author, when, full = parts + # Nombre de fichiers touchés par ce commit. + stat = _git("show", "--stat", "--oneline", "--format=", full) + files = [l for l in stat.stdout.splitlines() if "|" in l] + commits.append({ + "hash": short, + "full_hash": full, + "subject": subject, + "author": author, + "when": when, + "files_changed": len(files), + }) + return {"commits": commits} + + +@router.get("/diff") +async def git_diff(hash: str | None = None) -> dict: + """Diff d'un commit (hash) ou des modifications non commitées si absent.""" + if hash: + if not hash.replace("-", "").isalnum(): + raise HTTPException(400, "hash invalide") + proc = _git("show", "--no-color", hash) + else: + proc = _git("diff", "--no-color", "HEAD") + if proc.returncode != 0: + raise HTTPException(404, "diff introuvable") + # Borne la taille pour ne pas noyer l'UI. + return {"diff": proc.stdout[:200_000]} + + +class RevertRequest(BaseModel): + hash: str + + +@router.post("/revert") +async def git_revert(req: RevertRequest) -> dict: + """Annule un commit en créant un commit inverse (git revert).""" + h = req.hash.strip() + if not h.replace("-", "").isalnum(): + raise HTTPException(400, "hash invalide") + proc = _git("revert", "--no-edit", h, timeout=30) + if proc.returncode != 0: + # Conflit ou commit introuvable : on nettoie un éventuel revert en cours. + _git("revert", "--abort") + detail = (proc.stderr or proc.stdout).strip()[:300] + raise HTTPException(409, f"revert impossible : {detail}") + return {"ok": True, "message": f"commit {h[:7]} annulé"} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3262d5f..0062ecc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -41,6 +41,42 @@ export async function getSystemStats(): Promise { return res.json(); } +// ── Git du workspace ───────────────────────────────────────────────────── +export interface GitCommit { + hash: string; + full_hash: string; + subject: string; + author: string; + when: string; + files_changed: number; +} + +export async function getGitLog(): Promise { + try { + const res = await fetch("/api/git/log"); + return (await res.json()).commits; + } catch { + return []; + } +} + +export async function getGitDiff(hash?: string): Promise { + const url = hash ? `/api/git/diff?hash=${encodeURIComponent(hash)}` : "/api/git/diff"; + const res = await fetch(url); + if (!res.ok) return ""; + return (await res.json()).diff; +} + +export async function revertCommit(hash: string): Promise<{ message: string }> { + const res = await fetch("/api/git/revert", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hash }), + }); + if (!res.ok) throw await apiError(res, "revert impossible"); + return res.json(); +} + export interface HardwareInfo { loki_gpus: { index: number; @@ -405,7 +441,7 @@ export async function deleteSession(id: string): Promise { /** Envoie un message et streame la réponse de l'agent via SSE. */ export async function streamChat( - body: { session_id: string; content: string; model?: string }, + body: { session_id: string; content: string; model?: string; mode?: string }, handlers: { onToken: (t: string) => void; onThinking: (t: string) => void; diff --git a/frontend/src/panels/ChatPanel.tsx b/frontend/src/panels/ChatPanel.tsx index f615a01..3817267 100644 --- a/frontend/src/panels/ChatPanel.tsx +++ b/frontend/src/panels/ChatPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useStore } from "../store/useStore"; -import { ChevronDown, ClipIcon, LokiMark, SendIcon } from "../components/Icon"; +import { ChevronDown, LokiMark, SendIcon } from "../components/Icon"; import { ToolCard } from "../components/ToolCard"; import { MessageContent } from "../components/MessageContent"; import type { Message, ToolCall } from "../api/client"; @@ -135,11 +135,9 @@ export function ChatPanel() { className="min-h-[40px] w-full resize-none bg-transparent text-[14px] leading-relaxed text-ink outline-none placeholder:text-muted-3" />
- +
@@ -175,6 +173,70 @@ export function ChatPanel() { } /** Plan d'exécution affiché avant le travail de l'agent. */ +const MODES = [ + { id: "plan", label: "Plan", icon: "🔍", desc: "Lecture seule : analyse et propose, sans rien modifier" }, + { id: "build", label: "Build", icon: "🔨", desc: "Normal : écrit les fichiers, confirme les commandes shell" }, + { id: "yolo", label: "Yolo", icon: "⚡", desc: "Auto : approuve tout, y compris le shell" }, +] as const; + +/** Sélecteur de mode d'exécution (Plan / Build / Yolo) dans le composer. */ +function ModeSelector() { + const { mode, setMode } = useStore(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, []); + + const current = MODES.find((m) => m.id === mode) ?? MODES[1]; + + return ( +
+ + {open && ( +
+ {MODES.map((m) => ( + + ))} +
+ )} +
+ ); +} + function PlanCard({ steps }: { steps: string[] }) { return (
+ setTab("git")}> + Git +
{/* URL bar */} @@ -96,8 +106,11 @@ export function PreviewPanel() { )}
- {/* Onglet Logs (fond sombre) */} - {tab === "logs" ? ( + {/* Onglet Git */} + {tab === "git" ? ( + + ) : /* Onglet Logs (fond sombre) */ + tab === "logs" ? (
{logs.length === 0 ? (
@@ -171,6 +184,122 @@ export function PreviewPanel() { ); } +/** Onglet Git : historique des commits, diff colorisé, retour arrière. */ +function GitPanel() { + const refreshFiles = useStore((s) => s.refreshFiles); + const [commits, setCommits] = useState([]); + const [selected, setSelected] = useState(null); + const [diff, setDiff] = useState(""); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + const load = () => getGitLog().then(setCommits).catch(() => {}); + useEffect(() => { + load(); + }, []); + + const openDiff = async (hash: string) => { + setSelected(hash); + setDiff("Chargement…"); + setDiff((await getGitDiff(hash)) || "(diff vide)"); + }; + + const doRevert = async (hash: string) => { + if (!window.confirm(`Annuler le commit ${hash.slice(0, 7)} ? (crée un commit inverse)`)) + return; + setBusy(hash); + setError(null); + try { + await revertCommit(hash); + await load(); + await refreshFiles(); + if (selected === hash) setSelected(null); + } catch (e) { + setError(e instanceof Error ? e.message : "revert impossible"); + } finally { + setBusy(null); + } + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + {/* Liste des commits */} +
+ {commits.length === 0 ? ( +
+ Aucun commit. L'agent commitera chaque modification de code ici. +
+ ) : ( + commits.map((c) => ( +
+ + +
+ )) + )} +
+ {/* Diff du commit sélectionné */} +
+ {selected ? ( + + ) : ( +
+ Clique un commit pour voir ses modifications. +
+ )} +
+
+ ); +} + +/** Rendu coloré d'un diff unifié. */ +function DiffView({ diff }: { diff: string }) { + return ( +
+      {diff.split("\n").map((line, i) => {
+        let color = "text-on-dark-2";
+        if (line.startsWith("+") && !line.startsWith("+++")) color = "text-ok";
+        else if (line.startsWith("-") && !line.startsWith("---")) color = "text-accent";
+        else if (line.startsWith("@@")) color = "text-info";
+        else if (/^(commit|Author|Date|diff|index)/.test(line))
+          color = "text-on-dark-3";
+        return (
+          
+ {line || " "} +
+ ); + })} +
+ ); +} + function Empty({ children }: { children: React.ReactNode }) { return (
diff --git a/frontend/src/panels/SettingsView.tsx b/frontend/src/panels/SettingsView.tsx index 0d458ba..0d80c40 100644 --- a/frontend/src/panels/SettingsView.tsx +++ b/frontend/src/panels/SettingsView.tsx @@ -541,7 +541,11 @@ function HardwareCard() {
{hw.gpu_override.name} ·{" "} {(hw.gpu_override.vram_total_mb / 1024).toFixed(0)} Go{" "} - (déclaré) + (valeur déclarée) +
+ C'est la valeur de GPU_VRAM_MB que tu as fixée, pas une + détection réelle. Corrige-la (ex. 16000 pour 16 Go) ou mets 0. +
) : (
diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index 1038e56..16c084b 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -75,6 +75,9 @@ interface LokiState { removeSession: (id: string) => Promise; sendMessage: (content: string) => Promise; stopStreaming: () => void; + + mode: "plan" | "build" | "yolo"; + setMode: (m: "plan" | "build" | "yolo") => void; } let activeStreamController: AbortController | null = null; @@ -106,6 +109,9 @@ export const useStore = create((set, get) => ({ config: null, availableTools: [], pendingShell: null, + mode: "build", + + setMode: (m) => set({ mode: m }), approveShell: async () => { const cmd = get().pendingShell; @@ -350,7 +356,12 @@ export const useStore = create((set, get) => ({ activeStreamController = controller; await streamChat( - { session_id: sid, content, model: get().selectedModel || undefined }, + { + session_id: sid, + content, + model: get().selectedModel || undefined, + mode: get().mode, + }, { onToken: (t) => set({ streamContent: get().streamContent + t }), onThinking: (t) => set({ streamThinking: get().streamThinking + t }),