Niveau supérieur (inspiré de Jean) : modes Plan/Build/Yolo + panneau Git & Diff

Modes d'exécution (composer) :
- routes/chat : champ mode ; _apply_mode adapte la config —
  plan = outils lecture seule + plan forcé ; yolo = confirm_shell off ;
  build = comportement normal.
- Frontend : ModeSelector dans le composer, mode dans le store + requête chat.

Panneau Git & Diff (le workspace est un dépôt git, Aider commite) :
- routes/git : /api/git/log (commits), /api/git/diff (commit ou working),
  /api/git/revert (git revert --no-edit, confiné au workspace).
- Frontend : onglet Git dans le PreviewPanel — liste des commits, diff
  colorisé, bouton Annuler (revert) avec rafraîchissement de l'arbo.

Bonus : le panneau Matériel explique que « GPU déclaré 12 Go » vient de
GPU_VRAM_MB (valeur manuelle) — à corriger en 16000 pour une 16 Go.

Tests : git log/diff/revert, modes plan/yolo/build via la route chat, builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
Claude committed 2026-07-15 23:48:06 +00:00
1 parent ffac092a7f
commit 552548e4b4
9 files changed
+388 -15

No files matched your search

+15
View File
@@ -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 - **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. 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 ## Intelligence augmentée
- **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes - **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes
+2 -1
View File
@@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles
from . import agent_config, coder, db, rag from . import agent_config, coder, db, rag
from .config import settings 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: async def _warm_default_model() -> None:
@@ -71,6 +71,7 @@ app.include_router(config.router)
app.include_router(shell.router) app.include_router(shell.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(benchmark.router) app.include_router(benchmark.router)
app.include_router(git.router)
@app.get("/api/health") @app.get("/api/health")
+24 -1
View File
@@ -31,6 +31,29 @@ class ChatRequest(BaseModel):
session_id: str session_id: str
content: str content: str
model: str | None = None 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: def _sse(event: str, data: dict) -> str:
@@ -153,7 +176,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
raise HTTPException(404, "session introuvable") raise HTTPException(404, "session introuvable")
model = req.model or session.get("model") or settings.default_model 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. # Premier message : titre la session avec un extrait.
if not db.list_messages(req.session_id): if not db.list_messages(req.session_id):
+92
View File
@@ -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é"}
+37 -1
View File
@@ -41,6 +41,42 @@ export async function getSystemStats(): Promise<SystemStats> {
return res.json(); 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<GitCommit[]> {
try {
const res = await fetch("/api/git/log");
return (await res.json()).commits;
} catch {
return [];
}
}
export async function getGitDiff(hash?: string): Promise<string> {
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 { export interface HardwareInfo {
loki_gpus: { loki_gpus: {
index: number; index: number;
@@ -405,7 +441,7 @@ export async function deleteSession(id: string): Promise<void> {
/** Envoie un message et streame la réponse de l'agent via SSE. */ /** Envoie un message et streame la réponse de l'agent via SSE. */
export async function streamChat( export async function streamChat(
body: { session_id: string; content: string; model?: string }, body: { session_id: string; content: string; model?: string; mode?: string },
handlers: { handlers: {
onToken: (t: string) => void; onToken: (t: string) => void;
onThinking: (t: string) => void; onThinking: (t: string) => void;
+67 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore"; 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 { ToolCard } from "../components/ToolCard";
import { MessageContent } from "../components/MessageContent"; import { MessageContent } from "../components/MessageContent";
import type { Message, ToolCall } from "../api/client"; 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" className="min-h-[40px] w-full resize-none bg-transparent text-[14px] leading-relaxed text-ink outline-none placeholder:text-muted-3"
/> />
<div className="mt-1.5 flex items-center gap-2"> <div className="mt-1.5 flex items-center gap-2">
<button className="flex h-8 w-8 items-center justify-center border-2 border-line text-ink-2"> <ModeSelector />
<ClipIcon />
</button>
<div <div
className="flex h-8 min-w-0 max-w-[220px] items-center gap-1.5 border-2 border-line px-2.5 text-[13px] text-ink-2" className="flex h-8 min-w-0 max-w-[180px] items-center gap-1.5 border-2 border-line px-2.5 text-[13px] text-ink-2"
title={selectedModel || undefined} title={selectedModel || undefined}
> >
<span className="h-2 w-2 border-2 border-line bg-accent" /> <span className="h-2 w-2 border-2 border-line bg-accent" />
@@ -175,6 +173,70 @@ export function ChatPanel() {
} }
/** Plan d'exécution affiché avant le travail de l'agent. */ /** 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<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={`flex h-8 items-center gap-1.5 border-2 border-line px-2.5 text-[13px] ${
mode === "plan"
? "bg-info text-white"
: mode === "yolo"
? "bg-accent text-white"
: "bg-card text-ink-2"
}`}
title={current.desc}
>
<span>{current.icon}</span>
<span>{current.label}</span>
<ChevronDown size={11} />
</button>
{open && (
<div className="absolute bottom-10 left-0 z-20 w-64 border-[3px] border-line bg-card p-1 shadow-hard">
{MODES.map((m) => (
<button
key={m.id}
onClick={() => {
setMode(m.id);
setOpen(false);
}}
className={`flex w-full flex-col items-start gap-0.5 px-2 py-2 text-left hover:bg-base ${
m.id === mode ? "bg-base" : ""
}`}
>
<span className="text-[13px] text-ink">
{m.icon} {m.label}
</span>
<span className="text-[11px] leading-tight text-muted-2">{m.desc}</span>
</button>
))}
</div>
)}
</div>
);
}
function PlanCard({ steps }: { steps: string[] }) { function PlanCard({ steps }: { steps: string[] }) {
return ( return (
<div <div
+134 -5
View File
@@ -1,9 +1,16 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useStore } from "../store/useStore"; import { useStore } from "../store/useStore";
import { downloadFile, type ToolCall } from "../api/client"; import {
downloadFile,
getGitDiff,
getGitLog,
revertCommit,
type GitCommit,
type ToolCall,
} from "../api/client";
import { DownloadIcon } from "../components/Icon"; import { DownloadIcon } from "../components/Icon";
type TabId = "preview" | "code" | "logs"; type TabId = "preview" | "code" | "logs" | "git";
/** Panneau droit : onglets Aperçu / Code / Logs. */ /** Panneau droit : onglets Aperçu / Code / Logs. */
export function PreviewPanel() { export function PreviewPanel() {
@@ -74,6 +81,9 @@ export function PreviewPanel() {
{logs.length} {logs.length}
</span> </span>
</Tab> </Tab>
<Tab active={tab === "git"} onClick={() => setTab("git")}>
Git
</Tab>
</div> </div>
{/* URL bar */} {/* URL bar */}
@@ -96,8 +106,11 @@ export function PreviewPanel() {
)} )}
</div> </div>
{/* Onglet Logs (fond sombre) */} {/* Onglet Git */}
{tab === "logs" ? ( {tab === "git" ? (
<GitPanel />
) : /* Onglet Logs (fond sombre) */
tab === "logs" ? (
<div className="scr mx-3 mb-3 flex-1 overflow-auto border-[3px] border-line bg-card-deep p-3 text-[12.5px]"> <div className="scr mx-3 mb-3 flex-1 overflow-auto border-[3px] border-line bg-card-deep p-3 text-[12.5px]">
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="py-10 text-center text-on-dark-3"> <div className="py-10 text-center text-on-dark-3">
@@ -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<GitCommit[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [diff, setDiff] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="scr mx-3 mb-3 flex flex-1 flex-col overflow-hidden border-[3px] border-line bg-card">
{error && (
<div className="border-b-2 border-line bg-warn/10 px-3 py-2 text-[12px] text-warn">
{error}
</div>
)}
{/* Liste des commits */}
<div className="scr max-h-[45%] overflow-auto border-b-2 border-line">
{commits.length === 0 ? (
<div className="p-4 text-center text-[12px] text-muted-2">
Aucun commit. L'agent commitera chaque modification de code ici.
</div>
) : (
commits.map((c) => (
<div
key={c.full_hash}
className={`flex items-center gap-2 border-b border-line-soft px-3 py-2 last:border-0 ${
selected === c.hash ? "bg-base" : ""
}`}
>
<button
onClick={() => openDiff(c.hash)}
className="min-w-0 flex-1 text-left"
title={c.subject}
>
<div className="truncate text-[13px] text-ink">{c.subject}</div>
<div className="text-[11px] text-muted-2">
{c.hash} · {c.when} · {c.files_changed} fichier
{c.files_changed > 1 ? "s" : ""}
</div>
</button>
<button
onClick={() => doRevert(c.hash)}
disabled={busy === c.hash}
className="flex-none border-2 border-line bg-card px-2 py-1 text-[11px] text-warn disabled:opacity-40"
title="Annuler ce commit (revert)"
>
{busy === c.hash ? "…" : "↶ Annuler"}
</button>
</div>
))
)}
</div>
{/* Diff du commit sélectionné */}
<div className="scr flex-1 overflow-auto bg-card-deep p-3">
{selected ? (
<DiffView diff={diff} />
) : (
<div className="py-8 text-center text-[12px] text-on-dark-3">
Clique un commit pour voir ses modifications.
</div>
)}
</div>
</div>
);
}
/** Rendu coloré d'un diff unifié. */
function DiffView({ diff }: { diff: string }) {
return (
<pre className="m-0 whitespace-pre-wrap text-[11.5px] leading-relaxed">
{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 (
<div key={i} className={color}>
{line || " "}
</div>
);
})}
</pre>
);
}
function Empty({ children }: { children: React.ReactNode }) { function Empty({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center"> <div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
+5 -1
View File
@@ -541,7 +541,11 @@ function HardwareCard() {
<div className="border-2 border-line bg-base px-3 py-2 text-[13px] text-ink"> <div className="border-2 border-line bg-base px-3 py-2 text-[13px] text-ink">
{hw.gpu_override.name} ·{" "} {hw.gpu_override.name} ·{" "}
{(hw.gpu_override.vram_total_mb / 1024).toFixed(0)} Go{" "} {(hw.gpu_override.vram_total_mb / 1024).toFixed(0)} Go{" "}
<span className="text-muted-2">(déclaré)</span> <span className="text-muted-2">(valeur déclarée)</span>
<div className="mt-1 text-[11px] text-muted-2">
C'est la valeur de <code>GPU_VRAM_MB</code> que tu as fixée, pas une
détection réelle. Corrige-la (ex. 16000 pour 16 Go) ou mets 0.
</div>
</div> </div>
) : ( ) : (
<div className="border-2 border-line bg-base px-3 py-2 text-[12px] text-muted-2"> <div className="border-2 border-line bg-base px-3 py-2 text-[12px] text-muted-2">
+12 -1
View File
@@ -75,6 +75,9 @@ interface LokiState {
removeSession: (id: string) => Promise<void>; removeSession: (id: string) => Promise<void>;
sendMessage: (content: string) => Promise<void>; sendMessage: (content: string) => Promise<void>;
stopStreaming: () => void; stopStreaming: () => void;
mode: "plan" | "build" | "yolo";
setMode: (m: "plan" | "build" | "yolo") => void;
} }
let activeStreamController: AbortController | null = null; let activeStreamController: AbortController | null = null;
@@ -106,6 +109,9 @@ export const useStore = create<LokiState>((set, get) => ({
config: null, config: null,
availableTools: [], availableTools: [],
pendingShell: null, pendingShell: null,
mode: "build",
setMode: (m) => set({ mode: m }),
approveShell: async () => { approveShell: async () => {
const cmd = get().pendingShell; const cmd = get().pendingShell;
@@ -350,7 +356,12 @@ export const useStore = create<LokiState>((set, get) => ({
activeStreamController = controller; activeStreamController = controller;
await streamChat( 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 }), onToken: (t) => set({ streamContent: get().streamContent + t }),
onThinking: (t) => set({ streamThinking: get().streamThinking + t }), onThinking: (t) => set({ streamThinking: get().streamThinking + t }),