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 ( - + + +
); })} + {deleteError && ( +
+ {deleteError} +
+ )}