diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py
index 37b396c..6fb64e0 100644
--- a/backend/app/ollama_client.py
+++ b/backend/app/ollama_client.py
@@ -79,6 +79,15 @@ class OllamaClient:
resp.raise_for_status()
return resp.json()
+ async def delete_model(self, name: str) -> dict:
+ """Supprime un modèle installé (/api/delete)."""
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+ resp = await client.request(
+ "DELETE", f"{self.host}/api/delete", json={"model": name}
+ )
+ resp.raise_for_status()
+ return resp.json() if resp.content else {"status": "success"}
+
async def pull_model(self, name: str) -> AsyncIterator[dict]:
"""Télécharge un modèle en streamant la progression (/api/pull)."""
async with httpx.AsyncClient(
diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py
index 45a7f04..e4e1d2a 100644
--- a/backend/app/routes/files.py
+++ b/backend/app/routes/files.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse
from ..config import settings
from ..tools import ToolError, _safe_path
@@ -47,3 +48,19 @@ async def file_content(path: str) -> dict:
raise HTTPException(404, "fichier introuvable")
with open(target, "r", encoding="utf-8", errors="replace") as f:
return {"path": path, "content": f.read()}
+
+
+@router.get("/download")
+async def download_file(path: str) -> FileResponse:
+ """Télécharge un fichier en conservant le confinement au workspace."""
+ try:
+ target = _safe_path(path)
+ except ToolError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ if not os.path.isfile(target):
+ raise HTTPException(404, "fichier introuvable")
+ return FileResponse(
+ target,
+ filename=os.path.basename(target),
+ media_type="application/octet-stream",
+ )
diff --git a/backend/app/routes/models.py b/backend/app/routes/models.py
index 4b20261..486e013 100644
--- a/backend/app/routes/models.py
+++ b/backend/app/routes/models.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
import httpx
-from fastapi import APIRouter
+from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
@@ -62,6 +62,25 @@ class PullRequest(BaseModel):
name: str
+class DeleteModelRequest(BaseModel):
+ name: str
+
+
+@router.delete("/models")
+async def delete_model(req: DeleteModelRequest) -> dict:
+ """Supprime explicitement un modèle de l'instance Ollama."""
+ if not req.name.strip():
+ raise HTTPException(400, "nom de modèle vide")
+ try:
+ await ollama.delete_model(req.name.strip())
+ except httpx.HTTPStatusError as exc:
+ detail = exc.response.text[:500] or str(exc)
+ raise HTTPException(502, f"Ollama : {detail}") from exc
+ except (httpx.HTTPError, OSError) as exc:
+ raise HTTPException(502, f"Ollama injoignable : {exc}") from exc
+ return {"deleted": req.name.strip()}
+
+
@router.post("/models/pull")
async def pull_model(req: PullRequest) -> StreamingResponse:
"""Télécharge un modèle en streamant la progression (SSE)."""
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 799c70a..e795dd4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import { LeftPanel } from "./panels/LeftPanel";
import { ChatPanel } from "./panels/ChatPanel";
import { PreviewPanel } from "./panels/PreviewPanel";
import { SettingsView } from "./panels/SettingsView";
+import { FilesView, HistoryView, ToolsView } from "./panels/ActivityViews";
import { useStore } from "./store/useStore";
export default function App() {
@@ -26,15 +27,22 @@ export default function App() {
- {view === "settings" ? (
-
- ) : (
+ {view === "chat" && (
<>
>
)}
+ {view === "history" &&
setView("chat")} />}
+ {view === "files" && (
+ <>
+
+
+ >
+ )}
+ {view === "tools" && setView("settings")} />}
+ {view === "settings" && }
);
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index e08a221..3dfeaeb 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -29,6 +29,18 @@ export async function getModels(): Promise<{
return res.json();
}
+export async function deleteModel(name: string): Promise {
+ const res = await fetch("/api/models", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name }),
+ });
+ if (!res.ok) {
+ const payload = await res.json().catch(() => null);
+ throw new Error(payload?.detail ?? `suppression refusée (${res.status})`);
+ }
+}
+
export interface Session {
id: string;
title: string;
@@ -127,6 +139,15 @@ export async function fileContent(path: string): Promise {
return (await res.json()).content;
}
+export function downloadFile(path: string): void {
+ const link = document.createElement("a");
+ link.href = `/api/files/download?path=${encodeURIComponent(path)}`;
+ link.download = path.split(/[\\/]/).pop() ?? "fichier";
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+}
+
export async function listSessions(): Promise {
const res = await fetch("/api/sessions");
return (await res.json()).sessions;
diff --git a/frontend/src/components/ActivityBar.tsx b/frontend/src/components/ActivityBar.tsx
index dce3ee2..870cf2a 100644
--- a/frontend/src/components/ActivityBar.tsx
+++ b/frontend/src/components/ActivityBar.tsx
@@ -8,11 +8,11 @@ import {
export type View = "chat" | "history" | "files" | "tools" | "settings";
-const items: { id: View; icon: React.ReactNode }[] = [
- { id: "chat", icon: },
- { id: "history", icon: },
- { id: "files", icon: },
- { id: "tools", icon: },
+const items: { id: View; label: string; icon: React.ReactNode }[] = [
+ { id: "chat", label: "Chat", icon: },
+ { id: "history", label: "Historique", icon: },
+ { id: "files", label: "Fichiers", icon: },
+ { id: "tools", label: "Outils", icon: },
];
const btn = (on: boolean) =>
@@ -37,6 +37,8 @@ export function ActivityBar({
key={it.id}
onClick={() => onChange(it.id)}
className={btn(active === it.id)}
+ title={it.label}
+ aria-label={it.label}
>
{it.icon}
@@ -47,6 +49,8 @@ export function ActivityBar({
diff --git a/frontend/src/panels/ActivityViews.tsx b/frontend/src/panels/ActivityViews.tsx
new file mode 100644
index 0000000..40b7a61
--- /dev/null
+++ b/frontend/src/panels/ActivityViews.tsx
@@ -0,0 +1,158 @@
+import { useEffect } from "react";
+import { downloadFile, type FileNode } from "../api/client";
+import { DownloadIcon } from "../components/Icon";
+import { useStore } from "../store/useStore";
+
+export function HistoryView({ onOpen }: { onOpen: () => void }) {
+ const { sessions, refreshSessions, openSession, removeSession } = useStore();
+
+ useEffect(() => {
+ refreshSessions();
+ }, [refreshSessions]);
+
+ return (
+
+
+ {sessions.map((session) => (
+
+
{session.title}
+
+ {session.message_count ?? 0} message(s) · {new Date(session.updated_at * 1000).toLocaleString("fr-FR")}
+
+
+
+
+
+
+ ))}
+ {sessions.length === 0 &&
}
+
+
+ );
+}
+
+export function FilesView() {
+ const { fileTree, refreshFiles } = useStore();
+
+ useEffect(() => {
+ refreshFiles();
+ }, [refreshFiles]);
+
+ return (
+
+
+ {fileTree.length ? : }
+
+
+ );
+}
+
+function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
+ const { openPreview, previewPath } = useStore();
+ return (
+ <>
+ {nodes.map((node) => (
+
+
+
+ {node.type === "file" && (
+
+ )}
+
+ {node.children &&
}
+
+ ))}
+ >
+ );
+}
+
+export function ToolsView({ onSettings }: { onSettings: () => void }) {
+ const { config, availableTools, refreshConfig, selectedModel } = useStore();
+
+ useEffect(() => {
+ refreshConfig();
+ }, [refreshConfig, selectedModel]);
+
+ return (
+
+
+ {availableTools.map((name) => {
+ const enabled = config?.tools[name] ?? false;
+ return (
+
+
+ {name}
+
+ {enabled ? "ACTIF" : "INACTIF"}
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+function Page({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
+ return (
+
+
+
{title}
+
{subtitle}
+ {children}
+
+
+ );
+}
+
+function Empty({ text }: { text: string }) {
+ return {text}
;
+}
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} o`;
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
+}
diff --git a/frontend/src/panels/LeftPanel.tsx b/frontend/src/panels/LeftPanel.tsx
index 0032c9c..4b6f21a 100644
--- a/frontend/src/panels/LeftPanel.tsx
+++ b/frontend/src/panels/LeftPanel.tsx
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useStore } from "../store/useStore";
-import { PlusIcon } from "../components/Icon";
-import type { FileNode } from "../api/client";
+import { DownloadIcon, PlusIcon } from "../components/Icon";
+import { downloadFile, type FileNode } from "../api/client";
/** Panneau gauche : historique des sessions + arborescence de fichiers. */
export function LeftPanel() {
@@ -115,7 +115,7 @@ function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
n.type === "file" && openPreview(n.path)}
- className={`mb-[3px] flex items-center gap-2 px-2 py-[5px] ${
+ className={`group mb-[3px] flex items-center gap-2 px-2 py-[5px] ${
n.type === "file"
? active
? "cursor-pointer border-2 border-line bg-card-deep text-white"
@@ -133,7 +133,19 @@ function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
}`}
/>
)}
- {n.name}
+ {n.name}
+ {n.type === "file" && (
+
+ )}
{n.children &&
}
diff --git a/frontend/src/panels/PreviewPanel.tsx b/frontend/src/panels/PreviewPanel.tsx
index b7664b6..18e8382 100644
--- a/frontend/src/panels/PreviewPanel.tsx
+++ b/frontend/src/panels/PreviewPanel.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useStore } from "../store/useStore";
-import type { ToolCall } from "../api/client";
+import { downloadFile, type ToolCall } from "../api/client";
+import { DownloadIcon } from "../components/Icon";
type TabId = "preview" | "code" | "logs";
@@ -8,6 +9,30 @@ type TabId = "preview" | "code" | "logs";
export function PreviewPanel() {
const { previewPath, previewContent, messages, streamTools } = useStore();
const [tab, setTab] = useState("preview");
+ const [width, setWidth] = useState(() => {
+ const saved = Number(window.localStorage.getItem("loki.preview.width"));
+ return Number.isFinite(saved) && saved >= 300 ? saved : 452;
+ });
+
+ const updateWidth = (next: number) => {
+ const bounded = Math.max(300, Math.min(next, window.innerWidth - 520));
+ setWidth(bounded);
+ window.localStorage.setItem("loki.preview.width", String(bounded));
+ };
+
+ const startResize = (event: React.PointerEvent) => {
+ event.preventDefault();
+ const startX = event.clientX;
+ const startWidth = width;
+ const onMove = (move: PointerEvent) =>
+ updateWidth(startWidth + startX - move.clientX);
+ const onUp = () => {
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ };
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+ };
const isHtml = previewPath ? /\.html?$/.test(previewPath) : false;
@@ -18,7 +43,23 @@ export function PreviewPanel() {
];
return (
-
+
+
{
+ if (event.key === "ArrowLeft") updateWidth(width + 20);
+ if (event.key === "ArrowRight") updateWidth(width - 20);
+ }}
+ className="absolute -left-[6px] top-0 z-20 h-full w-[9px] cursor-col-resize bg-transparent hover:bg-accent/50 focus:bg-accent/50 focus:outline-none"
+ title="Glisser pour redimensionner"
+ />
{/* Onglets */}
setTab("preview")}>
@@ -43,6 +84,16 @@ export function PreviewPanel() {
{previewPath ? `workspace/${previewPath}` : "workspace/"}
+ {previewPath && (
+
+ )}
{/* Onglet Logs (fond sombre) */}
diff --git a/frontend/src/panels/SettingsView.tsx b/frontend/src/panels/SettingsView.tsx
index baf7db7..ccf42f8 100644
--- a/frontend/src/panels/SettingsView.tsx
+++ b/frontend/src/panels/SettingsView.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useStore } from "../store/useStore";
-import { pullModel } from "../api/client";
+import { deleteModel, pullModel } from "../api/client";
import type { AgentConfig } from "../api/client";
import { DownloadIcon, RefreshIcon } from "../components/Icon";
@@ -40,6 +40,22 @@ export function SettingsView() {
const [pullName, setPullName] = useState("");
const [pullStatus, setPullStatus] = useState
(null);
const [pullPct, setPullPct] = useState(0);
+ const [deletingModel, setDeletingModel] = useState(null);
+ const [deleteError, setDeleteError] = useState(null);
+
+ const doDeleteModel = async (name: string) => {
+ if (!window.confirm(`Supprimer définitivement le modèle ${name} ?`)) return;
+ setDeletingModel(name);
+ setDeleteError(null);
+ try {
+ await deleteModel(name);
+ await refreshModels();
+ } catch (err) {
+ setDeleteError(err instanceof Error ? err.message : "suppression impossible");
+ } finally {
+ setDeletingModel(null);
+ }
+ };
const doPull = async () => {
if (!pullName.trim()) return;
@@ -134,49 +150,57 @@ export function SettingsView() {
{models.map((m) => {
const on = m.name === selectedModel;
return (
-